Skip to content

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.

numpy_basics.py
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:

vectorize_vs_loop.py
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.

broadcasting.py
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.

pandas_basics.py
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:

analytics_pipeline.py
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=True for all assignments.
  • 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.
  • The boolean mask is evaluated lazily and was never applied.
  • Integer column b cannot 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 & / |, not and / or, for boolean filters on Series.
  • pandas for exploration; polars or DuckDB when data grows beyond memory or performance becomes the bottleneck.