If You Know SQL, You Already Know Most of pandas

You open a pandas tutorial and the first thing you see is df.groupby(...).agg(...), then something called .loc, then a reset_index() that nobody explained. And you think, "I already know how to do this. I just know it in SQL." Good news: you do. Most of pandas is SQL wearing different clothes. Once you see the mapping, you stop memorising pandas and start translating. So let's do exactly that. Same small data, SQL on the left, pandas on the right, and the real output. At the end, we'll look at the four places where they behave differently, because that's where beginners lose marks. I ran everything below with Python 3.14, pandas 3.0.2 and SQLite 3.53. The SQL results come from SQLite and the pandas results come from pandas, so when I say they match, I actually compared them. The data Two tables: customers and orders. import pandas as pd import numpy as np customers = pd.DataFrame({ "id": [1, 2, 3, 4], "name": ["Asha", "Ravi", "Meera", "Kiran"], "city": [

If You Know SQL, You Already Know Most of pandas

You open a pandas tutorial and the first thing you see is df.groupby(...).agg(...), then something called .loc, then a reset_index() that nobody explained.

And you think, "I already know how to do this. I just know it in SQL."

Good news: you do. Most of pandas is SQL wearing different clothes. Once you see the mapping, you stop memorising pandas and start translating.

So let's do exactly that. Same small data, SQL on the left, pandas on the right, and the real output. At the end, we'll look at the four places where they behave differently, because that's where beginners lose marks.

I ran everything below with Python 3.14, pandas 3.0.2 and SQLite 3.53. The SQL results come from SQLite and the pandas results come from pandas, so when I say they match, I actually compared them.

The data

Two tables: customers and orders.

import pandas as pd
import numpy as np

customers = pd.DataFrame({
    "id": [1, 2, 3, 4],
    "name": ["Asha", "Ravi", "Meera", "Kiran"],
    "city": ["Pune", "Delhi", "Pune", "Mumbai"],
})

orders = pd.DataFrame({
    "order_id": [101, 102, 103, 104, 105, 106],
    "customer_id": [1, 1, 2, 3, 3, 5],
    "amount": [500, 1200, 300, 800, None, 150],
    "status": ["paid", "paid", "refunded", "paid", "pending", "paid"],
})
 order_id  customer_id  amount   status
      101            1   500.0     paid
      102            1  1200.0     paid
      103            2   300.0 refunded
      104            3   800.0     paid
      105            3     NaN  pending
      106            5   150.0     paid

Two things are planted on purpose. Order 105 has a missing amount. Order 106 belongs to customer 5, who doesn't exist in customers. Real data is always like this.

(If you want to run the SQL side too, orders.to_sql("orders", conn) with a sqlite3 connection loads these tables in one line.)

1. SELECT columns

SELECT order_id, amount FROM orders;
orders[["order_id", "amount"]]

Two square brackets. The outer one is "get from the DataFrame", the inner one is a list of column names. If you write one bracket with one name, you get a Series (a single column) instead of a table.

2. WHERE

SELECT * FROM orders WHERE status = 'paid' AND amount >= 500;
orders[(orders["status"] == "paid") & (orders["amount"] >= 500)]

Output, same for both:

 order_id  customer_id  amount status
      101            1   500.0   paid
      102            1  1200.0   paid
      104            3   800.0   paid

Three changes to remember:

  • = becomes ==
  • AND / OR become & / |
  • every condition goes inside its own parentheses

That last one is not optional. Try it without the brackets:

orders[orders["status"] == "paid" & orders["amount"] >= 500]
TypeError: Cannot perform 'rand_' with a dtyped [float64] array and scalar of type [bool]

Python evaluates & before ==, so it tries to AND a string with a column. The error message makes no sense until you know why. Now you know.

3. ORDER BY and LIMIT

SELECT * FROM orders ORDER BY amount DESC LIMIT 3;
orders.sort_values("amount", ascending=False).head(3)
 order_id  customer_id  amount status
      102            1  1200.0   paid
      104            3   800.0   paid
      101            1   500.0   paid

4. GROUP BY

SELECT status,
       COUNT(*)      AS n,
       COUNT(amount) AS n_amount,
       SUM(amount)   AS total,
       AVG(amount)   AS avg_amt
FROM orders
GROUP BY status;
orders.groupby("status").agg(
    n=("order_id", "size"),
    n_amount=("amount", "count"),
    total=("amount", "sum"),
    avg_amt=("amount", "mean"),
).reset_index()

Output from pandas:

  status  n  n_amount  total  avg_amt
    paid  4         4 2650.0    662.5
 pending  1         0    0.0      NaN
refunded  1         1  300.0    300.0

The pattern name=("column", "function") is called named aggregation, and it's the closest thing pandas has to SUM(amount) AS total. Learn this one form and you can skip a lot of confusing older syntax.

.reset_index() turns the group keys back into a normal column. Without it, status becomes the row label. Think of it as "give me a plain table again".

Notice the pending row: total is 0.0 in pandas. Hold that thought, because SQL says something different. We'll get to it.

5. HAVING

pandas has no HAVING. You group first, then filter the grouped result, which is what HAVING was doing all along.

SELECT customer_id, SUM(amount) AS total
FROM orders
GROUP BY customer_id
HAVING SUM(amount) > 1000;
t = orders.groupby("customer_id", as_index=False).agg(total=("amount", "sum"))
t[t["total"] > 1000]
 customer_id  total
           1 1700.0

as_index=False does the same job as reset_index() here.

6. JOIN

SELECT c.name, o.order_id, o.amount
FROM orders o
JOIN customers c ON o.customer_id = c.id;
orders.merge(customers, left_on="customer_id", right_on="id", how="inner")[
    ["name", "order_id", "amount"]
]
 name  order_id  amount
 Asha       101   500.0
 Asha       102  1200.0
 Ravi       103   300.0
Meera       104   800.0
Meera       105     NaN

Order 106 is gone, because customer 5 doesn't exist. That's an inner join: only rows that match on both sides. Change how="inner" to how="left" and the order comes back:

SELECT o.order_id, c.name
FROM orders o
LEFT JOIN customers c ON o.customer_id = c.id;
orders.merge(customers, left_on="customer_id", right_on="id", how="left")[
    ["order_id", "name"]
]
 order_id  name
      101  Asha
      102  Asha
      103  Ravi
      104 Meera
      105 Meera
      106   NaN

how takes "inner", "left", "right" and "outer". That's INNER, LEFT, RIGHT and FULL OUTER.

"Customers who never ordered"

A favourite interview question. In SQL it's a LEFT JOIN and WHERE ... IS NULL:

SELECT c.name
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.order_id IS NULL;

Same idea in pandas:

m = customers.merge(orders, left_on="id", right_on="customer_id", how="left")
m[m["order_id"].isna()][["name"]]
 name
Kiran

IS NULL becomes .isna().

7. CASE WHEN

SELECT order_id, amount,
       CASE WHEN amount >= 1000 THEN 'big'
            WHEN amount >= 300  THEN 'mid'
            ELSE 'small' END AS size
FROM orders;
o = orders.copy()
o["size"] = np.select(
    [o["amount"] >= 1000, o["amount"] >= 300],
    ["big", "mid"],
    default="small",
)
o[["order_id", "amount", "size"]]
 order_id  amount  size
      101   500.0   mid
      102  1200.0   big
      103   300.0   mid
      104   800.0   mid
      105     NaN small
      106   150.0 small

np.select takes a list of conditions, a list of results, and a default. Just like WHEN ... THEN ... ELSE. Order matters in both: the first match wins.

Notice order 105 (missing amount) landed in small. Comparing NaN to anything is false, so it fell through to ELSE. SQL does the same, since a comparison with NULL is never true. Same result, same reason.

8. COALESCE

SELECT order_id, COALESCE(amount, 0) AS amount FROM orders;
orders.assign(amount=orders["amount"].fillna(0))

fillna(0) is COALESCE(x, 0). Row 105 now shows 0.0.

9. Window functions

SELECT order_id,
       ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY amount DESC) AS rn
FROM orders;
o = orders.copy()
o["rn"] = (
    o.sort_values("amount", ascending=False)
     .groupby("customer_id")
     .cumcount() + 1
)
o[["order_id", "rn"]]
 order_id  rn
      101   2
      102   1
      103   1
      104   1
      105   2
      106   1

Sort first (that's the ORDER BY), group (that's the PARTITION BY), then cumcount() numbers the rows inside each group starting from 0. We add 1 to match ROW_NUMBER. This one is the least obvious translation, so it's worth saving.

One small thing to know: SQLite puts NULLs last in a descending sort, and pandas also puts missing values last by default, so both agree that order 104 gets rn = 1 for customer 3. Other databases can place NULLs differently, so check before you trust a window function across engines.

Where pandas and SQL disagree

This is the part that costs people marks. All of these I ran on the data above.

1. Summing nothing

Order 105 is the only pending order, and its amount is missing.

SELECT SUM(amount) FROM orders WHERE status = 'pending';

SQL returns NULL. pandas:

orders[orders["status"] == "pending"]["amount"].sum()

returns 0.0. SQL says "I don't know", pandas says "zero". If you export that to a report, the two numbers mean different things. If you want SQL's behaviour, pass min_count=1: .sum(min_count=1) returns NaN when there is nothing to add.

2. Not-equal and missing values

SELECT COUNT(*) FROM orders WHERE amount != 500;
4
len(orders[orders["amount"] != 500])
5

SQL gives 4 because NULL != 500 is neither true nor false, so the row is dropped. pandas gives 5 because NaN != 500 is True. Same question, different counts. If missing values matter, filter them out explicitly with .notna().

3. COUNT vs size

orders.groupby("status")["amount"].count()   # paid 4, pending 0, refunded 1
orders.groupby("status").size()              # paid 4, pending 1, refunded 1

.count() skips missing values, like COUNT(amount). .size() counts rows, like COUNT(*). Beginners reach for .count() and quietly lose rows.

4. Checking for missing values

(orders["amount"] == None).sum()   # 0
orders["amount"].isna().sum()      # 1

== None finds nothing. It's the same mistake as writing = NULL in SQL, just in a different place. Use .isna(), your IS NULL.

Cheat sheet

SQL pandas
SELECT a, b df[["a", "b"]]
WHERE x = 1 AND y > 2 df[(df["x"] == 1) & (df["y"] > 2)]
ORDER BY a DESC LIMIT 3 df.sort_values("a", ascending=False).head(3)
GROUP BY ... SUM(x) AS t df.groupby("g").agg(t=("x", "sum"))
HAVING filter the grouped result
JOIN / LEFT JOIN df.merge(other, on=..., how="inner" / "left")
IS NULL .isna()
COALESCE(x, 0) .fillna(0)
CASE WHEN np.select(conditions, results, default=...)
COUNT(*) / COUNT(x) .size() / .count()
COUNT(DISTINCT x) .nunique()
ROW_NUMBER() OVER (PARTITION BY g ORDER BY x) sort_values("x").groupby("g").cumcount() + 1

Try it yourself

If you'd like to practise this translation, I've put some pandas problems on LakeBench, a platform I'm building. Two free ones that match this article: GroupBy with Multiple Aggregations and Merge DataFrames with Different Join Types.

A good exercise is to take any SQL query you've already written and translate it line by line, then compare the output. Which one felt most awkward in pandas, the window function or the join?

Share