NumPy & pandas Essentials¶
What this lesson gives you
NumPy provides the fast array engine; pandas builds the labeled, tabular data layer above it. Together they are the first tools any data work in Python reaches for — and the foundation of every analytics pipeline in a consulting context.
Estimated time: 22 min read · Part: Python Tooling & Data Fundamentals
Learning Objectives
After this lesson you will be able to: (1) create, index, and operate on NumPy arrays; (2) explain vectorization and why it is faster than Python loops; (3) understand broadcasting for element-wise operations on arrays with different shapes; (4) load, inspect, clean, and aggregate tabular data with pandas; (5) recognize when to use pandas versus NumPy versus pure Python.
NumPy: The Array Engine¶
NumPy's core object is the ndarray — an N-dimensional array of homogeneous, typed data stored in a single contiguous block of memory. The homogeneity is the key: every element has the same dtype (float64, int32, bool, etc.), which enables C-speed computation via vectorized operations implemented in Fortran/C under the hood. A loop over a million Python objects pays per-element overhead; the same computation on a NumPy array pays it once.
import numpy as np
# ── Creation ──────────────────────────────────────────────────────
arr = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) # from Python list
zeros = np.zeros((3, 4)) # 3×4 matrix of 0.0
ones = np.ones((2, 3), dtype=np.int32) # 2×3 matrix of 1 (int)
rng = np.arange(0, 10, 2) # [0, 2, 4, 6, 8]
linsp = np.linspace(0, 1, 5) # [0, 0.25, 0.5, 0.75, 1.0]
rand = np.random.default_rng(seed=42).standard_normal((1000,))
# ── Shape and dtype ───────────────────────────────────────────────
matrix = np.array([[1, 2, 3], [4, 5, 6]])
print(matrix.shape) # (2, 3) — 2 rows, 3 columns
print(matrix.dtype) # int64
print(matrix.ndim) # 2
# ── Indexing and slicing ──────────────────────────────────────────
arr[0] # 1.0 — first element
arr[-1] # 5.0 — last element
arr[1:4] # array([2.0, 3.0, 4.0])
matrix[0, :] # first row: array([1, 2, 3])
matrix[:, 1] # second column: array([2, 5])
# Boolean indexing — selects elements where condition is True
big = arr[arr > 3.0] # array([4.0, 5.0])
(2, 3) int64 2 Vectorized operations apply an operation to every element of an array in a single C-level loop, without any Python object overhead per iteration. This is not just syntactic sugar — it is a fundamental performance difference:
import numpy as np, time
prices = np.random.default_rng(0).uniform(10, 500, size=1_000_000)
# Slow: Python loop — 1 million iterations, Python overhead per element
t0 = time.perf_counter()
discounted_loop = [p * 0.9 for p in prices]
print(f"Loop: {time.perf_counter() - t0:.3f}s")
# Fast: vectorised operation — single C-level call
t0 = time.perf_counter()
discounted_vec = prices * 0.9
print(f"NumPy: {time.perf_counter() - t0:.3f}s")
Loop: 0.142s NumPy: 0.003s
Broadcasting: Operations on Different Shapes
NumPy broadcasting automatically expands arrays with fewer dimensions to match a larger array when the shapes are compatible. You can subtract a 1-D array of column means from a 2-D matrix without writing a loop — NumPy conceptually "broadcasts" the 1-D array across each row. The rule: dimensions are compared from the right; a dimension of size 1 is stretched to match the other. Mismatched non-1 dimensions raise a ValueError. Broadcasting is at the heart of why matrix operations in NumPy are both concise and fast.
import numpy as np
# Normalise a (100, 5) feature matrix to zero mean, unit variance per column
features = np.random.default_rng(1).standard_normal((100, 5))
col_means = features.mean(axis=0) # shape (5,) — mean of each column
col_stds = features.std(axis=0) # shape (5,)
# Broadcasting: (100, 5) - (5,) → NumPy broadcasts (5,) across 100 rows
normalised = (features - col_means) / col_stds # shape (100, 5)
print(normalised.mean(axis=0).round(10)) # ≈ [0, 0, 0, 0, 0]
print(normalised.std(axis=0).round(10)) # ≈ [1, 1, 1, 1, 1]
[0. 0. 0. 0. 0.] [1. 1. 1. 1. 1.]
pandas DataFrames¶
pandas builds on NumPy to add labeled tabular data: a DataFrame is a table where rows have an Index and columns have string names. This makes it the natural tool for working with CSV files, database query results, and API responses — real data has headers and row identifiers, not just integer positions. Under the hood, each column is a NumPy array (or an extension type for nullable integers, strings, etc.), so column operations are still vectorized and fast.
import pandas as pd
# ── Creating DataFrames ───────────────────────────────────────────
df = pd.DataFrame({
"user_id": ["u1", "u2", "u3", "u1", "u2"],
"event": ["click", "view", "click", "purchase", "click"],
"value": [5, 2, 7, 120, 3],
"country": ["IN", "US", "IN", "IN", "US"],
})
# Load from CSV (the most common source)
# df = pd.read_csv("events.csv", parse_dates=["timestamp"])
# ── Inspection ───────────────────────────────────────────────────
df.shape # (5, 4)
df.dtypes # dtypes of each column
df.head(3) # first 3 rows
df.info() # non-null counts + dtypes summary
df.describe() # min, max, mean, std for numeric columns
# ── Column selection ──────────────────────────────────────────────
df["value"] # Series (single column)
df[["user_id", "value"]] # DataFrame (multiple columns)
# ── Filtering (boolean indexing, same concept as NumPy) ───────────
clicks = df[df["event"] == "click"]
high_value = df[df["value"] > 10]
india_clicks = df[(df["country"] == "IN") & (df["event"] == "click")]
| Operation | Method | Notes |
|---|---|---|
| Load CSV | pd.read_csv(path) | Add parse_dates=[...] for datetime columns |
| Inspect shape | df.shape, df.info() | Check for nulls with df.isna().sum() |
| Select column | df["col"] → Series | Use df[["a","b"]] for multiple → DataFrame |
| Filter rows | df[df["col"] > x] | Combine with & / \|, not and / or |
| Add/transform column | df["new"] = df["a"] + df["b"] | Vectorized; no loop needed |
| Drop nulls | df.dropna(subset=["col"]) | Or fill: df.fillna(0) |
| Group & aggregate | df.groupby("col").agg(...) | Core analytics operation |
| Merge / join | pd.merge(df1, df2, on="key") | Like SQL JOIN |
| Export | df.to_csv(), df.to_parquet() | Prefer parquet for large data |
Real-World Data Pipeline¶
A typical consulting data task: receive raw event data from a client's system, clean it, aggregate it into metrics, and produce a summary report. Here is a complete end-to-end pandas pipeline representing that pattern:
import pandas as pd
import numpy as np
# ── 1. Load ───────────────────────────────────────────────────────
df = pd.read_csv(
"raw_events.csv",
parse_dates=["timestamp"],
dtype={"user_id": "string", "event": "string", "country": "string"},
)
print(f"Loaded: {len(df):,} rows, {df.isna().sum().sum()} nulls")
# ── 2. Clean ──────────────────────────────────────────────────────
# Drop rows with missing critical fields
df = df.dropna(subset=["user_id", "event", "value"])
# Clip outliers at the 99th percentile (common for event value data)
p99 = df["value"].quantile(0.99)
df["value"] = df["value"].clip(upper=p99)
# Normalise string columns
df["country"] = df["country"].str.upper().str.strip()
df["event"] = df["event"].str.lower().str.strip()
# Add derived columns
df["date"] = df["timestamp"].dt.date
df["hour"] = df["timestamp"].dt.hour
# ── 3. Aggregate: revenue by country and event type ───────────────
summary = (
df
.groupby(["country", "event"])
.agg(
sessions = ("user_id", "nunique"),
total_value = ("value", "sum"),
avg_value = ("value", "mean"),
events = ("event", "count"),
)
.round(2)
.reset_index()
.sort_values("total_value", ascending=False)
)
# ── 4. Merge with a reference table ──────────────────────────────
country_meta = pd.read_csv("country_metadata.csv") # has "country", "region"
summary = pd.merge(summary, country_meta, on="country", how="left")
# ── 5. Export ─────────────────────────────────────────────────────
summary.to_csv("summary_report.csv", index=False)
summary.to_parquet("summary_report.parquet", index=False) # columnar, fast
print(summary.head(10).to_string(index=False))
Loaded: 842,317 rows, 0 nulls country event sessions total_value avg_value events region IN purchase 1247 94821.50 76.04 1247 APAC US purchase 891 87342.00 97.97 891 AMER IN click 8391 41955.00 5.00 8391 APAC
Common pandas Gotchas
Chained indexing: df[df["a"] > 0]["b"] = 1 may silently modify a copy, not the original. Always use df.loc[mask, "b"] = 1. Boolean operators: use & and | with parentheses, not Python's and/or — the Python operators don't work element-wise on Series. Implicit type inference: pandas may infer a column as object dtype (Python strings) when you expect string — check dtypes after loading and cast explicitly. Memory: a naive pandas DataFrame uses 8 bytes per float64 value; on a 10M-row table with 50 columns that is 4 GB. Use pd.read_csv(..., dtype={"col": "float32"}) or downcast where precision allows.
Consulting Lens: pandas Is a Prototyping Tool
pandas excels at interactive exploration and analysis of data that fits in memory (up to a few GB on a modern laptop). For production pipelines processing hundreds of GB or running on a schedule, evaluate polars (faster and more memory-efficient for large DataFrames), DuckDB (SQL on local files with outstanding performance), or Spark/BigQuery for truly large-scale work. The consulting move: use pandas to prototype and validate the logic quickly, then hand the client a recommendation on whether to scale up to polars or a distributed system based on their data volumes and team skills.
flowchart LR
A[Raw CSV / API] --> B[pd.read_csv / httpx]
B --> C[Inspect<br/>shape / dtypes / nulls]
C --> D[Clean<br/>dropna / clip / normalise]
D --> E[Transform<br/>derived columns]
E --> F[Aggregate<br/>groupby / agg]
F --> G[Merge<br/>join reference data]
G --> H[Export<br/>CSV / Parquet / DB] Knowledge check
InterviewWhy is a NumPy vectorized operation like arr * 0.9 orders of magnitude faster than a Python for loop over the same data?
- NumPy uses multiple CPU cores by default while Python loops use one.
- A NumPy array stores homogeneous typed data in contiguous memory; the operation is a single C-level loop with no Python object overhead per element, versus a loop that must create and garbage-collect Python float objects at each iteration.
- Python loops check types at every iteration; NumPy skips type-checking entirely.
- NumPy automatically compiles the operation to GPU code.
Answer
A NumPy array stores homogeneous typed data in contiguous memory; the operation is a single C-level loop with no Python object overhead per element, versus a loop that must create and garbage-collect Python float objects at each iteration.
The performance gap has two sources: memory layout and Python overhead. A Python list contains pointers to heap-allocated float objects; iterating it means dereferencing pointers, manipulating reference counts, and calling the float multiplication method at every step. A NumPy array is a contiguous block of, say, float64 values — the CPU can read them linearly (cache-friendly), and the vectorized C routine processes them without any Python layer. On a million-element array this is typically 50–100× faster.
Knowledge check
PracticalYou filter a pandas DataFrame with df[df["a"] > 0]["b"] = 99 but the original DataFrame is unchanged. What went wrong?
- Pandas requires
inplace=Truefor all assignments. - Chained indexing —
df[mask]may return a copy, so assigning to["b"]modifies the copy, not the original. Usedf.loc[df["a"] > 0, "b"] = 99instead. - The boolean mask is evaluated lazily and was never applied.
- Integer column
bcannot hold the value 99.
Answer
Chained indexing — df[mask] may return a copy, so assigning to ["b"] modifies the copy, not the original. Use df.loc[df["a"] > 0, "b"] = 99 instead.
Chained indexing ( df[...][...] ) is ambiguous in pandas — it may return a copy or a view depending on internal optimizations, and the behavior is not guaranteed. Pandas may raise a SettingWithCopyWarning but not always. The correct, explicit pattern is df.loc[mask, "col"] = value , which always operates on the original DataFrame in-place.
Knowledge check
ConceptWhen would you choose polars or DuckDB over pandas for a data task?
- Always — pandas is deprecated in Python 3.12.
- When data exceeds available memory, performance is critical, or you prefer SQL syntax (DuckDB); pandas excels for interactive exploration of smaller datasets and has the broadest ecosystem.
- polars and DuckDB are only for machine learning tasks.
- You would choose polars when the data is in JSON format.
Answer
When data exceeds available memory, performance is critical, or you prefer SQL syntax (DuckDB); pandas excels for interactive exploration of smaller datasets and has the broadest ecosystem.
pandas loads everything into memory and is single-threaded on most operations. polars uses a Rust engine, processes data in parallel, and has lazy execution with query optimization — significantly faster and more memory-efficient on large DataFrames. DuckDB is an in-process SQL engine that can query Parquet/CSV files larger than memory. The pragmatic choice: prototype in pandas (broad ecosystem, familiar API), then benchmark and migrate to polars/DuckDB if performance or memory becomes the bottleneck.
Exercise
Given a DataFrame with columns user_id (string), event (string), value (float), and country (string): (1) filter to rows where value > 0; (2) add a column value_gbp that converts value from USD using an exchange rate of 0.79; (3) group by country to compute total events and mean value_gbp; (4) sort by total events descending. Write this as a single chained pandas expression.
Show solution
import pandas as pd
GBP_RATE = 0.79
result = (
df
.loc[df["value"] > 0]
.assign(value_gbp=lambda d: d["value"] * GBP_RATE)
.groupby("country", as_index=False)
.agg(
total_events = ("event", "count"),
mean_gbp = ("value_gbp", "mean"),
)
.sort_values("total_events", ascending=False)
.reset_index(drop=True)
)
Key Takeaways
- NumPy ndarrays store typed data contiguously — vectorized operations are C-speed, far faster than Python loops.
- Broadcasting expands lower-dimensional arrays element-wise; avoid explicit loops for column/row operations.
- pandas DataFrames add labeled rows and columns on top of NumPy; the workhorse of tabular data in Python.
- Use
df.loc[mask, "col"] = value, never chained indexing, to avoid silent copy mutations. - Combine with
&/|, notand/or, for boolean filters on Series. - pandas for exploration; polars or DuckDB when data grows beyond memory or performance becomes the bottleneck.