3.2 pandas — the data engineer's Swiss army knife¶
Concept¶
pandas is Python's in-memory table library. Its DataFrame is a spreadsheet-in-code: named, typed columns you can filter, transform, group, and join — the same operations you did in SQL, now in Python. It's the tool you reach for to inspect data, prototype transforms, clean a file, or reshape an API response before loading it.
pandas vs Spark — when to use which
pandas runs on one machine, in memory — perfect up to ~a few million rows. When data is too big for one machine, you use Spark (Unit 4), whose DataFrame API is deliberately pandas-like. Learn the shapes here; they carry over.
What a DataFrame is¶
Before any code, get the two core objects clear in your head — everything else in this lesson is just a method you call on one of them:
- A DataFrame is a table held in memory: it has rows and named, typed columns.
Think of a spreadsheet or a SQL table that lives inside your Python program. Each column has a
single dtype (data type) —
int64,float64,object(text),bool,datetime64, and so on — so pandas knows how to store and operate on it fast. - A Series is a single column — one named, typed array of values plus an index (the row labels). When you pull one column out of a DataFrame, you get a Series. A DataFrame is essentially a dict of Series that all share the same index.
The index — the row labels
Every DataFrame and Series carries an index: the labels down the left edge. By default it's
just the row numbers 0, 1, 2, …, but it can be any labels. The index is what loc uses to
look rows up, and it's what pandas aligns on when you combine objects. For now: default
integer index = "row number."
The verbs you already know from SQL all have a pandas twin. Keep this map in mind as you go:
| SQL | pandas |
|---|---|
SELECT col |
df["col"] |
WHERE x > 0 |
df[df["x"] > 0] |
GROUP BY … |
df.groupby(…).agg(…) |
JOIN |
df.merge(other, on=…, how=…) |
ORDER BY |
df.sort_values(…) |
First, import the library. The alias pd is a universal convention — every pandas tutorial,
answer, and codebase uses it, so pd.something reads the same everywhere:
Read it step by step:
import pandas— load the library.as pd— bind it to the short namepd. From now on,pd.DataFrame,pd.cut, etc.
Lab¶
Create & inspect¶
A DataFrame is columns of data. Build one, then look at it — inspection is the first thing you always do with any new dataset, so you know its shape, its columns' types, and whether it has gaps before you trust it.
Here we build a DataFrame from a dict: each key becomes a column name, and its list of values becomes that column's cells. All the lists must be the same length — one value per row. (In real work you'd instead read a DataFrame from a file or a database — see the note below — but a hand-built one is perfect for learning the verbs.)
df = pd.DataFrame({
"order_id": [1, 2, 3, 4, 5],
"country": ["US", "UK", "US", "DE", "UK"],
"amount": [120.0, 40.0, 300.0, 90.0, 55.0],
"status": ["delivered", "cancelled", "delivered", "delivered", "delivered"],
})
df.head() # first rows
df.shape # (rows, cols) → (5, 4)
df.info() # columns, dtypes, non-null counts
df.describe() # summary stats for numeric columns
df.dtypes # the type of each column
Read it step by step:
pd.DataFrame({...})— build a table. The dict's keys ("order_id","country", …) become the four column names; each value list becomes that column, top to bottom. Result: a 5-row × 4-column DataFrame with a default index0…4.df.head()— show the first rows (5 by default; pass a number for more/fewer). Your go-to "let me eyeball it" command. Its twindf.tail()shows the last rows.df.shape— the DataFrame's dimensions as a tuple(rows, columns)→ here(5, 4). Not a method call, just an attribute (no parentheses).df.info()— a per-column summary: the column names, each one's dtype, and its non-null count (how many cells are filled). The quickest way to spot missing data and wrong types.df.describe()— summary statistics (count, mean, std, min, quartiles, max) for the numeric columns only. Great for a fast sanity check on ranges and outliers.df.dtypes— the type of each column. Hereorder_idisint64,amountisfloat64, and the text columns areobject. An attribute, likeshape.
In real life you read a DataFrame, you don't type it
You'll rarely hand-build a DataFrame. Instead you load one with a read_* function, and pandas
infers the columns and dtypes for you:
pd.read_csv("orders.csv")— read a CSV file into a DataFrame.pd.read_parquet("orders.parquet")— read a Parquet file (columnar, typed, compressed — the format you'll meet in Unit 4). Faster and safer than CSV because the types are stored in the file.pd.read_sql("SELECT * FROM orders", conn)— run SQL against a database connection and pull the result straight into a DataFrame.
Whatever the source, once it's a DataFrame the rest of this lesson is identical.
Select columns & rows¶
Picking out pieces of a DataFrame is the pandas equivalent of SELECT. There are two axes:
choosing columns (with []) and choosing rows (with loc/iloc). Watch the bracket
count — one name gives you a Series; a list of names gives you a DataFrame.
df["amount"] # one column (a Series)
df[["country", "amount"]] # several columns (a DataFrame)
df.loc[0] # a row by label
df.iloc[0:2] # rows by position (first two)
df.loc[df["amount"] > 100] # boolean filter — the pandas WHERE
Read it step by step:
df["amount"]— single square brackets with one column name → returns that column as a Series (a 1-D labelled array). This isSELECT amount.df[["country", "amount"]]— double brackets = a list of columns → returns a DataFrame with just those columns, in that order. This isSELECT country, amount. Remember the rule: list in → DataFrame out; name in → Series out.df.loc[0]—locselects by label. Here it grabs the row whose index label is0, returned as a Series (the row's values, indexed by column name).df.iloc[0:2]—ilocselects by integer position.0:2means positions 0 and 1 (the end is excluded, like normal Python slicing) → the first two rows as a DataFrame.df.loc[df["amount"] > 100]— you can passloca boolean mask (see the next block) to keep only the rows where the condition isTrue. This is the pandasWHERE.
loc vs iloc — label vs position
locworks with the index labels and the column names (df.loc[0, "amount"]). With the default integer index the label happens to equal the position, which is why they look alike at first — but reorder or filter the rows and the labels stay attached to their data while positions renumber.ilocworks with integer positions only (df.iloc[0, 2]= row 0, column 2), always counting from 0, always end-exclusive.
Filter (boolean indexing)¶
Boolean indexing is how pandas does WHERE, and it's worth understanding the two-step trick
underneath. A comparison like df["status"] == "delivered" doesn't return rows — it returns a
Series of True/False, one per row (the mask). Putting that mask inside df[...] then
keeps only the rows where it's True.
delivered = df[df["status"] == "delivered"]
big_us = df[(df["country"] == "US") & (df["amount"] > 100)] # & = and, | = or
Read it step by step:
df["status"] == "delivered"— build a boolean mask:Truefor each delivered row,Falseotherwise.df[...]around it keeps theTruerows →deliveredis the delivered orders.(df["country"] == "US") & (df["amount"] > 100)— two masks combined with&(element-wise and). Both must beTruefor a row to survive → US orders over 100.- Result grain is unchanged — still one row per order — you just have fewer rows.
Use & | ~ and wrap each condition in parentheses
Inside a mask you must use & (and), | (or), ~ (not) — not Python's and/
or/not, which only work on single True/False values, not whole Series. And because &
binds tighter than ==, each condition needs its own parentheses:
(df["country"] == "US") & (df["amount"] > 100). Forget them and you'll get a confusing error.
More filters: isin, between, str.contains, query() — and .filter()¶
Writing every condition as == / > gets clumsy fast. pandas has shortcuts that each build a
mask for you, plus query(), which lets you write the condition as a SQL-like string.
df[df["country"].isin(["UK", "DE"])] # IN ('UK', 'DE')
df[df["amount"].between(50, 150)] # BETWEEN 50 AND 150
df[~df["status"].str.contains("cancel")] # NOT LIKE '%cancel%'
df.query('country == "US" and amount > 100') # the whole WHERE as one string
Read it step by step:
-
df["country"].isin(["UK", "DE"])—isin(list)isTruewhen the value is one of the list's items — the pandasIN (...). Output: rows 1, 3, 4 (UK, DE, UK). -
df["amount"].between(50, 150)—between(low, high)isTruewhenlow <= value <= high— both ends included, exactly like SQLBETWEEN. Output: orders 1, 4, 5 (120, 90, 55); 40 and 300 fall outside. ~df["status"].str.contains("cancel")—.strunlocks text functions on a column of strings;.str.contains("cancel")isTruewhen the text contains that piece (likeLIKE '%cancel%'). The~flips the mask (not) → every order except the cancelled one: orders 1, 3, 4, 5.-
df.query('country == "US" and amount > 100')—query()takes the condition as a string and uses the column names directly — nodf[...]repeated, and inside the string you may write plainand/or/not. Same result asbig_usabove:
Use a Python variable in query() with @
Inside the string, a bare name means a column. Put @ in front to mean a Python
variable instead:
.filter() — careful, it picks columns, not rows. If you come from Spark, where
df.filter(...) is the WHERE, this one surprises everyone: pandas' .filter() keeps the
columns (labels) whose names match, and never looks at the data.
df.filter(items=["order_id", "amount"]) # exactly these columns
df.filter(like="ount") # names CONTAINING "ount"
df.filter(regex="^(c|s)") # names matching a regular expression
items=[...]— keep exactly the listed columns →order_id,amount(same asdf[["order_id", "amount"]]).like="ount"— keep columns whose name contains"ount"→country,amount.regex="^(c|s)"— keep columns whose name matches a regular expression (a text pattern):^= "starts with",(c|s)= "cors" →country,status.
To filter rows and pick columns, chain the two — mask first, then .filter():
Spark .filter() vs pandas .filter()
| You want | Spark | pandas |
|---|---|---|
Keep rows (WHERE) |
df.filter(col("amount") > 100) |
df[df["amount"] > 100] or df.query("amount > 100") |
Keep columns (SELECT) |
df.select("order_id", "amount") |
df[["order_id", "amount"]] or df.filter(items=[...]) |
Add & transform columns¶
New columns are computed from existing ones. The key idea is vectorisation: an operation like
df["amount"] * 1.2 runs on the whole column at once, not row by row — it's both shorter to
write and far faster than a Python loop.
df["with_tax"] = df["amount"] * 1.2 # vectorised arithmetic
df["band"] = pd.cut(df["amount"], [0, 100, 1000],
labels=["small", "large"]) # bucketing
df = df.assign(is_delivered = df["status"] == "delivered") # assign() returns a new df
Read it step by step:
df["with_tax"] = df["amount"] * 1.2— assigning to a new column name creates it. The right side multiplies every value inamountby 1.2 in one shot (vectorised arithmetic).pd.cut(df["amount"], [0, 100, 1000], labels=[...])— bucket a numeric column into bins. The list[0, 100, 1000]defines the bin edges (0–100 and 100–1000);labelsnames them. Each row gets the label of the bin itsamountfalls into → a categoricalbandcolumn.df.assign(is_delivered = ...)—assignadds one or more columns and returns a new DataFrame (it doesn't modifydfin place). That's why we catch it withdf = .... It's handy in method chains because it returns the whole DataFrame to keep chaining on.
Two ways to add a column — in place vs. a new frame
df["new"] = ...mutatesdfdirectly (in place).df.assign(new = ...)leavesdfuntouched and hands back a copy with the column added. Preferassignwhen you want to keep the original intact or chain several steps.
Group & aggregate (the pandas GROUP BY)¶
This is the pandas twin of SQL's GROUP BY + SUM/COUNT/AVG. groupby("country") splits the
rows into one bucket per country; .agg(...) then collapses each bucket into a single summary
row. Read the chain top to bottom — each line is one step.
# revenue & order count per country, delivered only
(df[df["status"] == "delivered"]
.groupby("country")
.agg(revenue=("amount", "sum"),
orders=("order_id", "count"),
avg_order=("amount", "mean"))
.reset_index()
.sort_values("revenue", ascending=False))
Read it step by step:
df[df["status"] == "delivered"]— first filter to delivered orders (theWHERE, applied before grouping — same order as SQL)..groupby("country")— bucket the remaining rows bycountry. Nothing is computed yet; you've just declared the grouping key..agg(revenue=("amount", "sum"), ...)— compute one value per bucket. This named-aggregation form readsoutput_name=(source_column, function):revenue= sum ofamount,orders= count oforder_id,avg_order= mean ofamount. The result has one row per country — that's the new grain..reset_index()— aftergroupby, the grouping key (country) becomes the index.reset_index()pushes it back to being an ordinary column, so the result is a plain 0,1,2-indexed DataFrame — usually what you want for further work or export..sort_values("revenue", ascending=False)— order the summary rows byrevenue, highest first (theORDER BY … DESC).
Reading a method chain
Wrapping the whole thing in (...) lets you put each .method() on its own line so the
pipeline reads like a recipe: filter → group → aggregate → tidy the index → sort. Each step
takes the DataFrame from the step above and passes its result to the next.
Sort, rename, drop, cast¶
A grab-bag of everyday reshaping verbs. Note that sort_values, rename, and drop all return
a new DataFrame and leave df unchanged unless you reassign the result — only the explicit
df[...] = ... line below actually mutates df.
df.sort_values("amount", ascending=False)
df.rename(columns={"amount": "gross_amount"})
df.drop(columns=["with_tax"])
df["order_id"] = df["order_id"].astype("int64")
df["status"].value_counts() # counts per distinct value — super handy
Read it step by step:
df.sort_values("amount", ascending=False)— return the rows ordered byamount, largest first. Dropascending=False(defaults toTrue) for smallest-first. Pass a list of columns to sort by several keys.df.rename(columns={"amount": "gross_amount"})— rename columns via an{old: new}mapping. Only the listed columns change.df.drop(columns=["with_tax"])— remove one or more columns by name.df["order_id"].astype("int64")— cast a column to another dtype (here to 64-bit integer). Common for fixing columns that loaded as the wrong type. Reassigning it back todf["order_id"]makes the change stick.df["status"].value_counts()— count how many rows have each distinct value of a column, sorted most-frequent first. It'sGROUP BY status → COUNT(*)in a single call — a fast way to see the distribution of a categorical column.
Transpose — flip rows and columns¶
Transposing swaps the two axes: every row becomes a column and every column becomes a row. It's mostly a reading aid — a wide table (many columns, few rows) is much easier to scan when it's turned on its side.
small = df[["order_id", "country", "amount"]].head(3)
small.T # .T = transpose (same as small.transpose())
Read it step by step:
-
df[["order_id", "country", "amount"]].head(3)— a small 3-row × 3-column piece to flip (so the output stays readable): -
small.T—.Tis the transpose (an attribute, no parentheses;small.transpose()is the same thing as a method). The old column names become the index (row labels), and the old index0, 1, 2becomes the column names:
Make the columns meaningful by setting a real key as the index first — then those keys become the column headers after the flip:
set_index("order_id")— move theorder_idcolumn into the index (the row labels), so after.Tthe columns are named1, 2, 3by order id instead of by position.
Where it really helps — summaries. describe() gives one column per data column and one row
per statistic; with many columns that's hard to read. .T turns it into one row per
column:
And to lay a groupby result out horizontally, one column per group:
df.groupby("country")["amount"].sum()— totalamountper country, as a Series (index = country)..to_frame()— turn that Series into a one-column DataFrame (a Series has only one axis, so there's nothing to flip until it's a DataFrame)..T— flip it: the countries become the columns,amountthe single row.
Transposing mixed types turns everything into object
Each pandas column has one dtype. After a flip, a column holds one value from every old
column — here a number, a country code and an amount — so pandas falls back to the generic
object type:
Fine for looking at data; don't do maths on a transposed mixed table. Transpose to read, keep the original for work.
Transpose vs pivot
.T flips the whole table, labels and all — no grouping, no aggregation. If you want
to turn the values of a column into new columns (e.g. one column per month with revenue in
the cells), that's a pivot — next section.
Pivot — turn values into columns (and melt back)¶
A pivot takes the distinct values of one column and makes each of them a new column,
filling the cells with an aggregate. It's the spreadsheet "pivot table", and the SQL
SUM(CASE WHEN status = 'delivered' THEN amount END) … written for you. Here: revenue per country,
split by status.
p = df.pivot_table(index="country", columns="status",
values="amount", aggfunc="sum", fill_value=0)
p
Read it step by step:
index="country"— what becomes the rows: one row per distinct country (theGROUP BY).columns="status"— whose values become the new columns:cancelled,delivered.values="amount"— which column fills the cells.aggfunc="sum"— how to combine several rows that land in the same cell. US has two delivered orders (120 + 300), so its cell is 420. Other choices:"mean","count","max", … (the default is"mean").fill_value=0— what to put where no row exists (DE and US have no cancelled orders). Without it those cells would beNaN(missing).- The labels
status(above the columns) andcountry(above the index) are just the names of the two axes, so you can see where each came from.
Add totals with margins — an extra row and column with the grand totals:
df.pivot_table(index="country", columns="status", values="amount",
aggfunc="sum", fill_value=0, margins=True, margins_name="Total")
status cancelled delivered Total
country
DE 0.0 90.0 90.0
UK 40.0 55.0 95.0
US 0.0 420.0 420.0
Total 40.0 565.0 605.0
margins=True— add the totals row and column;margins_name="Total"labels them (the default label isAll).
Count instead of sum — how many orders per country and status:
pivot() vs pivot_table()
There is also a plain df.pivot(index=..., columns=..., values=...). It only reshapes —
it never aggregates, so every (index, column) pair must appear once. Our data has two
US / delivered rows, so it fails:
Rule of thumb: use pivot_table() — it works whether or not there are duplicates.
melt — the reverse (unpivot). Pivoting makes data wide (one column per status). Most
tools — Spark, SQL tables, charts — prefer it long (one row per country and status).
melt() turns the columns back into rows:
wide = p.reset_index() # country back to an ordinary column
wide.columns.name = None # drop the leftover "status" axis label
long = wide.melt(id_vars="country", var_name="status", value_name="amount")
long
country status amount
0 DE cancelled 0.0
1 UK cancelled 40.0
2 US cancelled 0.0
3 DE delivered 90.0
4 UK delivered 55.0
5 US delivered 420.0
p.reset_index()— movecountryout of the index into a normal column (meltworks on columns).wide.columns.name = None— remove thestatusaxis name the pivot left above the columns, so it doesn't clash with the newstatuscolumn we're about to create.melt(id_vars="country", ...)—id_vars= the column(s) to keep as-is; every other column (cancelled,delivered) is unfolded into rows.var_name="status"— name of the new column holding the old column names;value_name="amount"— name of the new column holding their values.
Transpose vs pivot vs melt
| Tool | What it does |
|---|---|
.T |
Flip the whole table — rows ↔ columns. No grouping. |
pivot_table() |
Long → wide: a column's values become new columns, cells aggregated. |
melt() |
Wide → long: columns become rows (unpivot). |
In Spark the same ideas are df.groupBy("country").pivot("status").sum("amount") and
df.unpivot(...); in SQL, PIVOT / UNPIVOT (or CASE WHEN).
Handle missing data¶
Real data has gaps. pandas represents a missing value as NaN / None (its NULL), and gives
you three tools: detect them (isna), drop the affected rows (dropna), or fill them
with a default (fillna).
df2 = df.copy()
df2.loc[1, "amount"] = None
df2["amount"].isna().sum() # how many nulls
df2 = df2.dropna(subset=["amount"]) # drop rows missing amount
df2 = df2.fillna({"country": "unknown"}) # or fill a default
Read it step by step:
df.copy()— make an independent copy so edits todf2don't touch the originaldf. (Without.copy(),df2could be a view ontodfand changes might leak back.)df2.loc[1, "amount"] = None— useloc[row_label, column]to set a single cell. Here we poke a null into row1'samountto have something to clean.df2["amount"].isna().sum()—isna()returns a boolean mask (Truewhere the value is missing); summing a boolean mask counts theTrues → how many nulls are in the column.df2.dropna(subset=["amount"])— drop rows that have a missing value in the listed columns. Withoutsubsetit drops rows with a null in any column.df2.fillna({"country": "unknown"})— fill missing values. The dict form fills per-column: nulls incountrybecome"unknown". Bothdropnaandfillnareturn a new DataFrame, hence thedf2 = ...reassignment.
Join two DataFrames¶
merge is the pandas JOIN: it stitches two DataFrames together on a shared key column,
exactly like the joins you did in Unit 2. Here we attach a
region to each order by matching on country.
countries = pd.DataFrame({"country": ["US", "UK", "DE"],
"region": ["NA", "EU", "EU"]})
enriched = df.merge(countries, on="country", how="left") # SQL LEFT JOIN
Read it step by step:
countries = pd.DataFrame({...})— a small lookup table mapping each country to a region.df.merge(countries, ...)— joindf(left) tocountries(right).on="country"— the join key: rows match where theircountryvalues are equal (the pandasON o.country = c.country). Theregioncolumn comes along onto each matched order row.how="left"— a LEFT JOIN: keep every row ofdf; if a country has no match incountries, itsregioncomes back asNaN. Other options are"inner"(only matches),"right", and"outer"(all rows from both) — identical to SQL join types.- Result:
enrichedhas all ofdf's rows plus the newregioncolumn.
merge mirrors SQL joins one-for-one
df.merge(other, on="key", how="left") is SELECT * FROM df LEFT JOIN other USING(key).
If the key columns have different names, use left_on= / right_on= instead of on=.
Just like SQL, if the key isn't unique on the right side the join fans out to more rows —
watch for that.
apply / map — custom row logic¶
When there's no built-in, apply a Python function (slower — prefer vectorised ops when you can):
Read it step by step:
df["amount"].apply(func)—applyruns the given function on each value of the column and collects the results into a new Series.lambda a: "large" if a >= 100 else "small"— an inline one-argument function:ais oneamountvalue; it returns"large"or"small". So each row is labelled by its own amount.- Assigning the result to
df["band2"]stores it as a new column.
apply is your last resort, not your first
apply calls your Python function once per row, so it's much slower than a vectorised
expression on the whole column. Reach for a built-in / vectorised form first
(df["amount"] * 1.2, pd.cut(...), boolean masks) and use apply only when the logic
genuinely can't be expressed that way.
Challenge¶
Build a region scorecard. This ties the lesson together — a join and a group-by:
mergetheregionlookup (from Join two DataFrames above) onto the orders.- Keep delivered orders only.
- Per
region(not country), compute total revenue, order count, and average order value. - Sort by revenue, highest first.
The twist vs. the Group & aggregate example: you group by the joined-in region, so you
first have to merge, then group.
Solution
countries = pd.DataFrame({"country": ["US", "UK", "DE"],
"region": ["NA", "EU", "EU"]})
enriched = df.merge(countries, on="country", how="left") # attach region (LEFT JOIN)
region_scorecard = (
enriched[enriched["status"] == "delivered"] # delivered only
.groupby("region") # ← the joined-in key
.agg(revenue=("amount", "sum"),
orders=("order_id", "count"),
avg_order=("amount", "mean"))
.reset_index()
.sort_values("revenue", ascending=False)
)
region_scorecard
Expected result (from the 5-row sample df):
🎯 The same shapes on Azure, Databricks, Snowflake & Fabric
What you just did: filtered, transformed, grouped, and joined DataFrames.
- Azure Databricks / Microsoft Fabric — run pandas directly in notebooks; and Spark's
DataFrame API (
.filter/.groupBy/.join) mirrors these shapes at scale — plus pandas API on Spark (import pyspark.pandas as ps) is pandas that runs distributed. - Snowflake — Snowpark's DataFrame API mirrors pandas;
to_pandas()bridges them. - Azure Data Factory — the no-code Mapping Data Flow expresses the same filter/derive/ aggregate/join steps as visual transformations.
pandas is the universal prototyping tool; these verbs reappear in every DataFrame API.
Key terms, at a glance¶
| Term | Plain meaning |
|---|---|
| DataFrame | A table in memory: rows + named, typed columns |
| Series | A single column — one named, typed array with an index |
| Index | The row labels down the left edge (default 0,1,2,…) |
| dtype | A column's type (int64, float64, object, bool…) |
read_csv / read_parquet / read_sql |
Load a DataFrame from a file / Parquet / a database |
head / shape / info / describe / dtypes |
The inspection toolkit: first rows / (rows,cols) / column summary / stats / types |
df['col'] vs df[['a','b']] |
One name → a Series; a list → a DataFrame |
| Boolean indexing | df[df.col > x] — mask of True/False → the pandas WHERE |
& \| ~ |
Element-wise and / or / not inside a mask (wrap each condition in ()) |
loc / iloc |
Select by label / by integer position |
| Vectorised op | An operation on a whole column at once (fast) vs a Python loop |
assign |
Return a new DataFrame with column(s) added |
pd.cut |
Bucket a numeric column into labelled bins |
groupby().agg() |
Group + aggregate (the GROUP BY); reset_index() un-nests the key |
merge |
Join two DataFrames on a key (how='left'/'inner'/…) = SQL JOIN |
sort_values |
Order rows by one or more columns (the ORDER BY) |
rename / drop / astype |
Rename columns / drop columns / cast a column's type |
value_counts |
Count rows per distinct value (GROUP BY + COUNT in one) |
isna / dropna / fillna |
Detect / drop / fill missing values (NaN) |
apply / map |
Run a custom Python function per value (last resort — slower) |
| Grain | What one output row represents (per order, per country…) |
You can now…¶
- Create and inspect DataFrames (
head/info/describe/dtypes/shape) - Select, filter, add, and transform columns
- Group & aggregate, sort, join, and handle missing data
- Recognise how every pandas verb maps to SQL and to Spark