DEV Community

K HUNNURJI RAO
K HUNNURJI RAO

Posted on

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"],
})
Enter fullscreen mode Exit fullscreen mode
 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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode
orders[["order_id", "amount"]]
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode
orders[(orders["status"] == "paid") & (orders["amount"] >= 500)]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode
TypeError: Cannot perform 'rand_' with a dtyped [float64] array and scalar of type [bool]
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode
orders.sort_values("amount", ascending=False).head(3)
Enter fullscreen mode Exit fullscreen mode
 order_id  customer_id  amount status
      102            1  1200.0   paid
      104            3   800.0   paid
      101            1   500.0   paid
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode
orders.groupby("status").agg(
    n=("order_id", "size"),
    n_amount=("amount", "count"),
    total=("amount", "sum"),
    avg_amt=("amount", "mean"),
).reset_index()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode
t = orders.groupby("customer_id", as_index=False).agg(total=("amount", "sum"))
t[t["total"] > 1000]
Enter fullscreen mode Exit fullscreen mode
 customer_id  total
           1 1700.0
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode
orders.merge(customers, left_on="customer_id", right_on="id", how="inner")[
    ["name", "order_id", "amount"]
]
Enter fullscreen mode Exit fullscreen mode
 name  order_id  amount
 Asha       101   500.0
 Asha       102  1200.0
 Ravi       103   300.0
Meera       104   800.0
Meera       105     NaN
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode
orders.merge(customers, left_on="customer_id", right_on="id", how="left")[
    ["order_id", "name"]
]
Enter fullscreen mode Exit fullscreen mode
 order_id  name
      101  Asha
      102  Asha
      103  Ravi
      104 Meera
      105 Meera
      106   NaN
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Same idea in pandas:

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

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;
Enter fullscreen mode Exit fullscreen mode
o = orders.copy()
o["size"] = np.select(
    [o["amount"] >= 1000, o["amount"] >= 300],
    ["big", "mid"],
    default="small",
)
o[["order_id", "amount", "size"]]
Enter fullscreen mode Exit fullscreen mode
 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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode
orders.assign(amount=orders["amount"].fillna(0))
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode
o = orders.copy()
o["rn"] = (
    o.sort_values("amount", ascending=False)
     .groupby("customer_id")
     .cumcount() + 1
)
o[["order_id", "rn"]]
Enter fullscreen mode Exit fullscreen mode
 order_id  rn
      101   2
      102   1
      103   1
      104   1
      105   2
      106   1
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

SQL returns NULL. pandas:

orders[orders["status"] == "pending"]["amount"].sum()
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode
4
Enter fullscreen mode Exit fullscreen mode
len(orders[orders["amount"] != 500])
Enter fullscreen mode Exit fullscreen mode
5
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

.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
Enter fullscreen mode Exit fullscreen mode

== 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?

Top comments (0)