Generics & Functional Data Processing - Python

Quote

“All non-trivial abstractions, to some degree, are leaky.”

Joel Spolsky, The Law of Leaky Abstractions, blog post (2002)

Python is already generic at runtime through duck typing: any object with the required methods can participate in the operation. Type hints add static contracts when you want library-grade APIs, and the later iterator and DataFrame sections show the common Python equivalents for C# LINQ-style transforms.

from typing import Callable, Generic, Optional, Protocol, TypeVar, runtime_checkable
from itertools import groupby
from collections import defaultdict
from functools import reduce
import pandas as pd
import numpy as np
import pyodbc
import polars as pl

Generics

Python’s type system is fundamentally different from C#‘s. Duck typing already gives you runtime flexibility, while the typing module adds optional static contracts for editors, CI, and reusable APIs. The examples below move from implicit generic behavior to bounded type variables and structural typing.

Python | Generics | duck typing, bounds, and structural typing

These examples show where Python stays dynamic by default and where typing adds useful guarantees without changing the runtime dispatch model.

Duck typing — no generics needed

Python’s dynamic typing means functions work on any iterable — list, tuple, string, set, generator — without type declarations. This is “duck typing”: if it quacks like a duck, it’s a duck. Simpler than C#/Java generics for most use cases. For public library APIs, add type hints for documentation and type checking.

def first_element(items):
    """Works with ANY iterable — list, tuple, string, set..."""
    for item in items:
        return item
    return None
 
print(first_element([1, 2, 3]))
print(first_element("hello"))
print(first_element((10, 20)))
1
h
10

TypeVar — generic type hints

This cell uses both an unconstrained TypeVar and a constrained one. The output proves that the annotations preserve the original runtime values while giving the type checker enough information to track int, str, and numeric-only call sites separately.

T = TypeVar("T")
 
def first(items: list[T]) -> Optional[T]:
    return items[0] if items else None
 
result_int: Optional[int] = first([1, 2, 3])
result_str: Optional[str] = first(["a", "b", "c"])
print(f"int: {result_int}, str: {result_str}")
 
Number = TypeVar("Number", int, float)
 
def add(a: Number, b: Number) -> Number:
    return a + b
 
print(add(3, 4))
print(add(3.5, 4.5))
# add("a", "b")  # type checker flags this — Python still runs it
int: 1, str: a
7
8.0

Bounded TypeVar — method access from a bound

Use bound= when generic code needs a method or protocol that arbitrary objects do not have. Here the bound guarantees abs() is valid for every item, so the function can return the original concrete type instead of falling back to object.

class SupportsAbs(Protocol):
    def __abs__(self) -> float: ...
 
AbsT = TypeVar("AbsT", bound=SupportsAbs)
 
def largest_magnitude(values: list[AbsT]) -> AbsT:
    return max(values, key=abs)
 
print(largest_magnitude([3, -7, 2]))
print(largest_magnitude([1.5, -4.25, 2.0]))
-7
-4.25

Protocol — structural typing without inheritance

Protocol lets the type checker reason about capabilities instead of ancestry. The runtime check is optional: with @runtime_checkable, isinstance() succeeds for objects that implement the required method, even if they never inherit from the protocol.

@runtime_checkable
class SupportsClose(Protocol):
    def close(self) -> str: ...
 
class FileHandle:
    def close(self) -> str:
        return "closed file handle"
 
class Logger:
    def write(self, message: str) -> None:
        pass
 
handle = FileHandle()
print(isinstance(handle, SupportsClose))
print(handle.close())
print(isinstance(Logger(), SupportsClose))
True
closed file handle
False

Generic class — Generic[T]

Inherit from Generic[T] so the type checker tracks what’s inside. The last lines also demonstrate runtime erasure: the parameterized stacks share the same runtime class, and isinstance(..., Stack[int]) is not allowed.

class Stack(Generic[T]):
    def __init__(self) -> None:
        self._items: list[T] = []
 
    def push(self, item: T) -> None:
        self._items.append(item)
 
    def pop(self) -> T:
        return self._items.pop()
 
    def peek(self) -> T:
        return self._items[-1]
 
    def __len__(self) -> int:
        return len(self._items)
 
    def __repr__(self) -> str:
        return f"Stack({self._items})"
 
int_stack: Stack[int] = Stack()
int_stack.push(1)
int_stack.push(2)
int_stack.push(3)
print(int_stack)
print(int_stack.pop())
 
str_stack: Stack[str] = Stack()
str_stack.push("hello")
str_stack.push("world")
print(str_stack)
print(type(int_stack) is type(str_stack))
try:
    print(isinstance(int_stack, Stack[int]))
except TypeError as exc:
    print(type(exc).__name__)
    print(exc)
Stack([1, 2, 3])
3
Stack(['hello', 'world'])
True
TypeError
Subscripted generics cannot be used with class and instance checks

Common generic type annotations — built-in collections and typing aliases

PEP 585 moved the core collection generics onto the built-in container types in Python 3.9+, but helpers such as Optional and Callable still come from typing unless you use newer syntax such as str | None.

AnnotationMeaningImport note
list[int]Typed listBuilt-in in Python 3.9+
dict[str, int]Typed dictionaryBuilt-in in Python 3.9+
set[str]Typed setBuilt-in in Python 3.9+
tuple[int, str]Fixed-length typed tupleBuilt-in in Python 3.9+
Optional[str]str or NoneImport from typing, or write `str
Callable[[int], bool]Function signatureImport from typing

Functional Data Processing

Python’s built-in functional tools provide the same pipeline vocabulary as C# LINQ, but they operate on plain iterables rather than query objects. This section shows the core iterator behaviors first, then applies the same ideas to grouping, joining, zipping, and flattening.

Python | Functional tools | generators, map/filter/reduce, groupby, zip

The examples below use the same employee/department data as the C# note, expressed as plain dictionaries.

Sample data

This shared setup keeps the later examples focused on the transformation itself. The records are simple enough to show grouping, joining, sorting, and flattening without hiding the iterator behavior behind a larger framework.

employees = [
    {"name": "Alice", "dept": "Engineering", "salary": 95000, "level": "senior"},
    {"name": "Bob", "dept": "Sales", "salary": 65000, "level": "junior"},
    {"name": "Charlie", "dept": "Engineering", "salary": 110000, "level": "lead"},
    {"name": "Diana", "dept": "Sales", "salary": 78000, "level": "senior"},
    {"name": "Eve", "dept": "Engineering", "salary": 88000, "level": "junior"},
    {"name": "Frank", "dept": "Marketing", "salary": 72000, "level": "senior"},
]
 
departments = [
    {"dept": "Engineering", "budget": 500000, "head": "CTO"},
    {"dept": "Sales", "budget": 300000, "head": "VP Sales"},
    {"dept": "Marketing", "budget": 200000, "head": "CMO"},
    {"dept": "HR", "budget": 150000, "head": "CHRO"},
]

Generator expressions — lazy and single-pass

Generator expressions are the lazy companion to list comprehensions. The output shows that values are produced only as they are consumed, and that a generator is empty once it has been exhausted.

squares = (x * x for x in range(4))
print(next(squares))
print(list(squares))
print(list(squares))
0
[1, 4, 9]
[]

map(), filter(), and reduce()

This example covers the three standard-library functions that most directly mirror LINQ method chains. The output proves that map() and filter() are iterator-producing wrappers, while reduce() returns the final scalar result.

nums = [1, 2, 3, 4]
mapped = map(lambda x: x * 10, nums)
filtered = filter(lambda x: x % 2 == 0, nums)
 
print(type(mapped).__name__)
print(type(filtered).__name__)
print(list(mapped))
print(list(filtered))
print(reduce(lambda acc, x: acc + x, nums, 0))
map
filter
[10, 20, 30, 40]
[2, 4]
10

GroupBy and aggregations

itertools.groupby requires the input to be sorted by the grouping key first. It yields (key, group_iterator) pairs — the group iterator is consumed once, so convert it to a list if you need multiple passes. Python’s equivalent of C#‘s GroupBy + .Average() / .Sum().

from itertools import groupby
from statistics import mean
 
sorted_emps = sorted(employees, key=lambda e: e["dept"])
for dept, group in groupby(sorted_emps, key=lambda e: e["dept"]):
    members = list(group)
    names = [m["name"] for m in members]
    avg_sal = mean(m["salary"] for m in members)
    print(f"  {dept:<15} ({len(members)} people): {names} avg=${avg_sal:,.0f}")
Engineering     (3 people): ['Alice', 'Charlie', 'Eve'] avg=$97,667
Marketing       (1 people): ['Frank'] avg=$72,000
Sales           (2 people): ['Bob', 'Diana'] avg=$71,500

Join — dictionary lookup

Python has no built-in join operator. The idiomatic approach is to build a dictionary from one collection, then look up matching keys from the other — equivalent to C#‘s Join. For a left join (all departments, even those with no employees), use dict.get with a default.

dept_lookup = {d["dept"]: d for d in departments}
 
inner_join = [
    {**e, "head": dept_lookup[e["dept"]]["head"], "budget": dept_lookup[e["dept"]]["budget"]}
    for e in employees
    if e["dept"] in dept_lookup
]
for r in inner_join[:3]:
    print(f"  {r['name']:<10} {r['dept']:<15} head={r['head']:<10} budget=${r['budget']:,}")
 
emp_by_dept = defaultdict(list)
for e in employees:
    emp_by_dept[e["dept"]].append(e)
 
for d in departments:
    count = len(emp_by_dept.get(d["dept"], []))
    print(f"  {d['dept']:<15} head={d['head']:<10} employees={count}")
Alice      Engineering     head=CTO        budget=$500,000
Bob        Sales           head=VP Sales   budget=$300,000
Charlie    Engineering     head=CTO        budget=$500,000
Engineering     head=CTO        employees=3
Sales           head=VP Sales   employees=2
Marketing       head=CMO        employees=1
HR              head=CHRO       employees=0

Chained pipeline and zip

Comprehensions chain naturally via nesting or sequential assignment. zip pairs elements from parallel iterables positionally, stopping at the shortest — equivalent to C#‘s Zip.

top3 = sorted(
    [{"name": e["name"], "salary": e["salary"], "tax": e["salary"] * 0.3}
     for e in employees if e["salary"] > 75000],
    key=lambda x: -x["salary"]
)[:3]
for r in top3:
    print(f"  {r['name']:<10} salary=${r['salary']:,}  tax=${r['tax']:,.0f}")
 
names = [e["name"] for e in employees]
salaries = [e["salary"] for e in employees]
raises = [e["salary"] * 0.1 for e in employees]
for name, salary, raise_amt in zip(names, salaries, raises):
    print(f"  {name:<10} ${salary:>8,} + ${raise_amt:>7,.0f} raise")
Charlie    salary=$110,000  tax=$33,000
Alice      salary=$95,000  tax=$28,500
Eve        salary=$88,000  tax=$26,400
Alice      $  95,000 + $  9,500 raise
Bob        $  65,000 + $  6,500 raise
Charlie    $ 110,000 + $ 11,000 raise
Diana      $  78,000 + $  7,800 raise
Eve        $  88,000 + $  8,800 raise
Frank      $  72,000 + $  7,200 raise

SelectMany — flatten nested collections

Nested list comprehensions are Python’s equivalent of C#‘s SelectMany. A double for in a comprehension iterates the outer collection then the inner, yielding a flat sequence.

people = [
    {"name": "Alice", "skills": ["Python", "LINQ", "SQL"]},
    {"name": "Bob", "skills": ["C#", "SQL"]},
    {"name": "Charlie", "skills": ["Python", "Go"]},
]
 
nested = [p["skills"] for p in people]
print(f"Select (nested): {nested}")
 
flat = [skill for p in people for skill in p["skills"]]
print(f"SelectMany (flat): {flat}")
 
pairs = [f"{p['name']}: {s}" for p in people for s in p["skills"]]
for pair in pairs:
    print(f"  {pair}")
 
distinct = sorted(set(skill for p in people for skill in p["skills"]))
print(f"Distinct skills: {distinct}")
 
matrix = [[1, 2, 3], [4, 5], [6, 7, 8, 9]]
print(f"Flat matrix: {[x for row in matrix for x in row]}")
Select (nested): [['Python', 'LINQ', 'SQL'], ['C#', 'SQL'], ['Python', 'Go']]
SelectMany (flat): ['Python', 'LINQ', 'SQL', 'C#', 'SQL', 'Python', 'Go']
  Alice: Python
  Alice: LINQ
  Alice: SQL
  Bob: C#
  Bob: SQL
  Charlie: Python
  Charlie: Go
Distinct skills: ['C#', 'Go', 'LINQ', 'Python', 'SQL']
Flat matrix: [1, 2, 3, 4, 5, 6, 7, 8, 9]

Pandas vs Polars Analytics

Side-by-side analytics on the same preserved snapshot boundary. Pandas reads SQL Server with an explicit 2026-03-12 cutoff, while Polars reads the matching Parquet extract so the HTML evidence tables remain comparable across both libraries.

Python | Data setup | SQL Server connection and data loading

This setup cell is the boundary between live data access and the preserved notebook evidence below. The password is read from an environment variable instead of being embedded in the note, and the date cutoff keeps the pandas SQL result aligned with the stored Polars snapshot and captured outputs.

Connect to SQL Server and load data

The code below loads the shared OHLCV and score slices that the pandas and Polars examples reuse later. The output confirms the aligned row counts, symbol count, and date range for the frozen snapshot used by this note.

import os
from sqlalchemy import create_engine
from urllib.parse import quote_plus
 
snapshot_end = "2026-03-12"
odbc_str = (
    'DRIVER={ODBC Driver 18 for SQL Server};'
    'SERVER=localhost,1434;DATABASE=stoxx;'
    f"UID=sa;PWD={os.environ['STOXX_SQL_PASSWORD']};"
    'Encrypt=yes;TrustServerCertificate=yes;'
)
engine = create_engine(f'mssql+pyodbc:///?odbc_connect={quote_plus(odbc_str)}')
 
ohlcv = pd.read_sql(
    f"SELECT symbol, date, [open], high, low, [close], adj_close, volume "
    f"FROM silver.eurostoxx50_ohlcv WHERE date <= '{snapshot_end}'",
    engine,
)
scores = pd.read_sql(
    f"SELECT symbol, sector, country, composite_score, composite_rank, "
    f"momentum_score, current_price, ytd_change_pct "
    f"FROM gold.scores_daily WHERE score_date <= '{snapshot_end}'",
    engine,
)
 
pldf = pl.read_parquet('C:/Users/aperi/DEV/LANG/data/eurostoxx50_ohlcv.parquet')
 
print(f"  Pandas: {len(ohlcv):,} rows, {ohlcv.symbol.nunique()} symbols")
print(f"  Polars: {pldf.height:,} rows")
print(f"  Date range: {ohlcv.date.min()} to {ohlcv.date.max()}")
print(f"  Scores rows: {len(scores):,}")
  Pandas: 66,355 rows, 50 symbols
  Polars: 66,355 rows
  Date range: 2021-01-04 to 2026-03-12
  Scores rows: 466

Subsetting

These pairs show the closest pandas and Polars equivalents for row slicing, column projection, and symbol-specific subsets on the same OHLCV snapshot.

Pandas — Subset rows by slicing with iloc[]

iloc[100:103] slices rows by integer position while leaving the original labeled index intact. The output proves pandas returns three consecutive rows with their existing row labels still visible.

ohlcv.iloc[100:103]
symboldateopenhighlowcloseadj_closevolumevol_rank
100ASML.AS2021-05-26549.7549.7538.8541.9518.6536538666924
101ASML.AS2021-05-27542.3547.3537.8544.0520.66341123807115
102ASML.AS2021-05-28544.8553.0542.1552.3528.6074562085869

Polars — Subset rows by slicing with slice()

slice(100, 3) is the Polars equivalent of positional row slicing. The output shows the same three-row window without a separate pandas-style index column.

pldf.slice(100, 3)
idsymboldateopenhighlowcloseadj_closevolumedividendsstock_splitsis_filled
i64strdatef64f64f64f64f64i64f64f64bool
21260"ABI.BR"2021-05-2661.9962.3961.8362.1258.67019401860.00.0false
21261"ABI.BR"2021-05-2761.862.6461.7362.1358.679517964770.00.0false
21262"ABI.BR"2021-05-2862.1462.5861.9662.3458.877910041250.00.0false

Pandas — Subset columns with double-bracket notation

Double brackets keep the result as a DataFrame instead of collapsing to a Series. The output confirms that only the requested four columns are materialized.

ohlcv[['symbol', 'date', 'close', 'volume']].head(5)
symboldateclosevolume
0ASML.AS2021-01-04406.25789502
1ASML.AS2021-01-05406.90798787
2ASML.AS2021-01-06402.85875711
3ASML.AS2021-01-07403.90874780
4ASML.AS2021-01-08416.05975243

Polars — Subset columns with select()

Polars uses select() for column projection, even in the simplest cases. The output shows the matching four-column subset plus explicit schema metadata above the table.

pldf.select('symbol', 'date', 'close', 'volume').head(5)
symboldateclosevolume
strdatef64i64
"ABI.BR"2021-01-0457.211513937
"ABI.BR"2021-01-0557.181382722
"ABI.BR"2021-01-0658.771370204
"ABI.BR"2021-01-0758.41469911
"ABI.BR"2021-01-0857.861428681

Pandas — Subset single row with iloc[n]

Selecting one row with iloc[0] returns a Series keyed by column name. The output proves pandas collapses the row into a one-dimensional labeled result rather than a one-row DataFrame.

ohlcv.iloc[0]
symbol          ASML.AS
date         2021-01-04
open              404.0
high              411.0
low              402.25
close            406.25
adj_close       387.709
volume           789502
vol_rank            392
Name: 0, dtype: object

Polars — Subset single row with row()

row(0, named=True) materializes one record as a Python mapping. The text output shows that Polars returns named scalar values instead of a pandas Series.

pldf.row(0, named=True)
{'id': 21160,
'symbol': 'ABI.BR',
'date': datetime.date(2021, 1, 4),
'open': 58.15,
'high': 58.85,
'low': 56.78,
'close': 57.21,
'adj_close': 53.5761,
'volume': 1513937,
'dividends': 0.0,
'stock_splits': 0.0,
'is_filled': False}

Pandas — Subset with loc[] label filter

This uses a boolean mask with loc[] to keep only date and close for one symbol. The output confirms pandas combines row filtering and column selection in a single label-oriented expression.

ohlcv.loc[ohlcv.symbol == 'ASML.AS', ['date', 'close']].head(5)
dateclose
02021-01-04406.25
12021-01-05406.90
22021-01-06402.85
32021-01-07403.90
42021-01-08416.05

Polars — Subset with filter() + select()

Polars has no row-label index, so the same subset is expressed as filter() followed by select(). The output shows the equivalent ASML date/close slice with the Polars schema banner.

pldf.filter(pl.col('symbol') == 'ASML.AS').select('date', 'close').head(5)
dateclose
datef64
2021-01-04406.25
2021-01-05406.9
2021-01-06402.85
2021-01-07403.9
2021-01-08416.05

Pandas — Subset multiple rows with iloc index

Passing a list of integer positions selects non-contiguous rows in the order requested. The output shows pandas preserving the original row labels for those sampled positions.

ohlcv.iloc[[0, 50, 100, 500]]
symboldateopenhighlowcloseadj_closevolumevol_rank
0ASML.AS2021-01-04404.0411.0402.25406.25387.7090789502392
50ASML.AS2021-03-15448.0455.1446.45453.95433.2320632242706
100ASML.AS2021-05-26549.7549.7538.80541.90518.6536538666924
500ASML.AS2022-12-09573.0579.2569.80577.30560.7872618610743

Polars — Subset multiple rows with index list

Indexing with a list pulls the same non-contiguous rows by position in Polars. The output confirms the selected records are returned without a dedicated row index.

pldf[[0, 50, 100, 500]]
idsymboldateopenhighlowcloseadj_closevolumedividendsstock_splitsis_filled
i64strdatef64f64f64f64f64i64f64f64bool
21160"ABI.BR"2021-01-0458.1558.8556.7857.2153.576115139370.00.0false
21210"ABI.BR"2021-03-1552.3253.0552.1852.2948.968612533120.00.0false
21260"ABI.BR"2021-05-2661.9962.3961.8362.1258.67019401860.00.0false
21660"ABI.BR"2022-12-0956.6456.9656.5456.8854.226210989050.00.0false

Pandas — Select columns

This is the minimal pandas projection pattern used throughout notebook work. The output confirms the frame is reduced to the requested three columns and keeps the default tabular display.

ohlcv[['symbol', 'date', 'close']].head(5)
symboldateclose
0ASML.AS2021-01-04406.25
1ASML.AS2021-01-05406.90
2ASML.AS2021-01-06402.85
3ASML.AS2021-01-07403.90
4ASML.AS2021-01-08416.05

Polars — Select columns

The equivalent Polars projection stays expression-oriented even when no computation is involved. The output shows the same three-column shape with inferred dtypes.

pldf.select('symbol', 'date', 'close').head(5)
symboldateclose
strdatef64
"ABI.BR"2021-01-0457.21
"ABI.BR"2021-01-0557.18
"ABI.BR"2021-01-0658.77
"ABI.BR"2021-01-0758.4
"ABI.BR"2021-01-0857.86

Pandas — Filter rows

This boolean mask keeps only ASML rows whose close is above 600 and then projects the relevant columns. The output proves pandas evaluates the mask eagerly and returns only matching rows.

ohlcv[(ohlcv.symbol == 'ASML.AS') & (ohlcv['close'] > 600)][['symbol', 'date', 'close']].head(5)
symboldateclose
135ASML.AS2021-07-14609.1
141ASML.AS2021-07-22620.8
142ASML.AS2021-07-23638.8
143ASML.AS2021-07-26638.0
144ASML.AS2021-07-27623.0

Polars — Filter rows

The same predicate becomes a column expression in Polars. The output confirms the filtered subset matches the pandas result while keeping the expression syntax explicit.

pldf.filter((pl.col('symbol') == 'ASML.AS') & (pl.col('close') > 600)).select('symbol', 'date', 'close').head(5)
symboldateclose
strdatef64
"ASML.AS"2021-07-14609.1
"ASML.AS"2021-07-22620.8
"ASML.AS"2021-07-23638.8
"ASML.AS"2021-07-26638.0
"ASML.AS"2021-07-27623.0

Pandas — Sort

sort_values(..., ascending=False) ranks the rows by trading volume. The output shows the highest-volume days rising to the top of the frame.

ohlcv.sort_values('volume', ascending=False)[['symbol', 'date', 'volume']].head(5)
symboldatevolume
31077ISP.MI2023-08-08376391539
10782SAN.MC2021-10-20367211467
31028ISP.MI2023-05-31317362978
30974ISP.MI2023-03-13311886033
10792SAN.MC2021-11-03306973344

Polars — Sort

Polars sorts the same snapshot with descending=True and then projects the interesting columns. The output confirms the same top-volume records are surfaced.

pldf.sort('volume', descending=True).select('symbol', 'date', 'volume').head(5)
symboldatevolume
strdatei64
"ISP.MI"2023-08-08376391539
"SAN.MC"2021-10-20367211467
"ISP.MI"2023-05-31317362978
"ISP.MI"2023-03-13311886033
"SAN.MC"2021-11-03306973344

Pandas — Add computed column

assign() creates a derived range column without mutating the original DataFrame in place. The output proves the calculated high-minus-low spread is available alongside the original columns.

ohlcv.assign(range=ohlcv.high - ohlcv.low)[['symbol', 'close', 'range']].head(5)
symbolcloserange
0ASML.AS406.258.75
1ASML.AS406.9010.90
2ASML.AS402.858.00
3ASML.AS403.907.45
4ASML.AS416.055.70

Polars — Add computed column

with_columns() is the Polars equivalent for derived expressions. The output shows the same range calculation added as a new column in the returned frame.

pldf.with_columns((pl.col('high') - pl.col('low')).alias('range')).select('symbol', 'close', 'range').head(5)
symbolcloserange
strf64f64
"ABI.BR"57.212.07
"ABI.BR"57.181.23
"ABI.BR"58.771.55
"ABI.BR"58.40.98
"ABI.BR"57.860.97

Aggregations

These examples translate SQL-style GROUP BY and HAVING logic into the native pandas and Polars aggregation APIs.

Pandas — GroupBy with aggregates

This groups rows by symbol and computes multiple aggregate columns in one pass. The output confirms pandas returns one row per symbol with named summary metrics.

ohlcv.groupby('symbol').agg(
    avg_close=('close', 'mean'),
    total_vol=('volume', 'sum'),
    days=('close', 'count')
).sort_values('avg_close', ascending=False).head(5).round(2)
avg_closetotal_voldays
symbol
RMS.PA1761.56816338621331
ADYEN.AS1545.981104004631331
ASML.AS671.359450707201331
MC.PA662.405578555671331
RHM.DE544.663083597441324

Polars — GroupBy with aggregates

Polars performs the same grouped reduction with expression-based aggregations. The output shows the grouped result as a regular DataFrame with explicit schema metadata.

pldf.group_by('symbol').agg(
    pl.col('close').mean().alias('avg_close'),
    pl.col('volume').sum().alias('total_vol'),
    pl.col('close').count().alias('days')
).sort('avg_close', descending=True).head(5)
symbolavg_closetotal_voldays
strf64i64u32
"RMS.PA"1761.555748816338621331
"ADYEN.AS"1545.9764091104004631331
"ASML.AS"671.3489119450707201331
"MC.PA"662.4045085578555671331
"RHM.DE"544.6615333083597441324

Pandas — HAVING

This reproduces SQL HAVING behavior by aggregating first and filtering the grouped result afterward. The output proves the threshold is applied to per-symbol summaries rather than raw rows.

avg_vol = ohlcv.groupby('symbol')['volume'].mean()
avg_vol[avg_vol > 5_000_000].sort_values(ascending=False).to_frame('avg_volume')
avg_volume
symbol
ISP.MI8.758860e+07
SAN.MC4.177099e+07
ENEL.MI2.467870e+07
BBVA.MC1.665446e+07
UCG.MI1.390371e+07
ENI.MI1.297621e+07
INGA.AS1.280359e+07
IBE.MC1.203484e+07
DTE.DE7.575084e+06
NDA-FI.HE5.375454e+06
TTE.PA5.138099e+06

Polars — HAVING

The Polars version applies the same post-aggregation filter inside an expression pipeline. The output shows only the groups that survive the aggregate condition.

pldf.group_by('symbol').agg(
    pl.col('volume').mean().alias('avg_vol')
).filter(pl.col('avg_vol') > 5_000_000).sort('avg_vol', descending=True)
symbolavg_vol
strf64
"ISP.MI"8.7589e7
"SAN.MC"4.1771e7
"ENEL.MI"2.4679e7
"BBVA.MC"1.6654e7
"UCG.MI"1.3904e7
"INGA.AS"1.2804e7
"IBE.MC"1.2035e7
"DTE.DE"7.5751e6
"NDA-FI.HE"5.3755e6
"TTE.PA"5.1381e6

Window Functions

Window functions let each row see neighboring or group-level context without collapsing the dataset. The pairs below keep the same ASML slice or per-symbol partition so the pandas and Polars semantics stay comparable.

Pandas — Window Function LAG()

shift(1) aligns each close with the prior trading day’s close after sorting by date. The output proves the first row has no lag value and later rows inherit the previous close.

asml = ohlcv[ohlcv.symbol == 'ASML.AS'].sort_values('date').copy()
asml['prev_close'] = asml['close'].shift(1)
asml['return_pct'] = ((asml['close'] - asml['prev_close']) / asml['prev_close'] * 100).round(2)
asml[['date', 'close', 'prev_close', 'return_pct']].tail(5)
datecloseprev_closereturn_pct
647312026-03-061147.01186.0-3.29
661552026-03-091147.61147.00.05
661562026-03-101200.01147.64.57
661572026-03-111198.81200.0-0.10
663052026-03-121190.81198.8-0.67

Polars — Window Function LAG()

Polars expresses the same lag with a shifted column over the filtered symbol slice. The output shows the same prior-close alignment as the pandas example.

pldf.filter(pl.col('symbol') == 'ASML.AS').sort('date').with_columns(
    pl.col('close').shift(1).over('symbol').alias('prev_close')
).with_columns(
    ((pl.col('close') - pl.col('prev_close')) / pl.col('prev_close') * 100).alias('return_pct')
).select('date', 'close', 'prev_close', 'return_pct').tail(5)
datecloseprev_closereturn_pct
datef64f64f64
2026-03-061147.01186.0-3.288364
2026-03-091147.61147.00.05231
2026-03-101200.01147.64.566051
2026-03-111198.81200.0-0.1
2026-03-121190.81198.8-0.667334

Pandas — Window Function Cumulative SUM()

This builds a running total of ASML volume over time. The output confirms the cumulative value increases row by row in date order.

asml = ohlcv[ohlcv.symbol == 'ASML.AS'].sort_values('date').copy()
asml['cum_vol'] = asml['volume'].cumsum()
asml[['date', 'volume', 'cum_vol']].tail(5)
datevolumecum_vol
647312026-03-06857271942889692
661552026-03-09689086943578778
661562026-03-10800815944379593
661572026-03-11562904944942497
663052026-03-12128223945070720

Polars — Window Function Cumulative SUM()

The Polars version computes the same running total as a column expression. The output shows the cumulative sum growing across the ordered rows.

pldf.filter(pl.col('symbol') == 'ASML.AS').sort('date').with_columns(
    pl.col('volume').cum_sum().over('symbol').alias('cum_vol')
).select('date', 'volume', 'cum_vol').tail(5)
datevolumecum_vol
datei64i64
2026-03-06857271942889692
2026-03-09689086943578778
2026-03-10800815944379593
2026-03-11562904944942497
2026-03-12128223945070720

Pandas — Window Function AVG() Moving Average

This applies a rolling average to smooth short-term price movement. The output shows the moving-average column filling in only once enough rows exist for the configured window.

asml = ohlcv[ohlcv.symbol == 'ASML.AS'].sort_values('date').copy()
asml['sma_20'] = asml['close'].rolling(20).mean()
asml[['date', 'close', 'sma_20']].tail(5).round(2)
dateclosesma_20
647312026-03-061147.01214.02
661552026-03-091147.61211.16
661562026-03-101200.01211.51
661572026-03-111198.81211.06
663052026-03-121190.81211.61

Polars — Window Function AVG() Moving Average

Polars computes the same moving window as an expression over the ordered slice. The output confirms the rolling average aligns with the pandas result.

pldf.filter(pl.col('symbol') == 'ASML.AS').sort('date').with_columns(
    pl.col('close').rolling_mean(20).over('symbol').alias('sma_20')
).select('date', 'close', 'sma_20').tail(5)
dateclosesma_20
datef64f64
2026-03-061147.01214.02
2026-03-091147.61211.16
2026-03-101200.01211.51
2026-03-111198.81211.06
2026-03-121190.81211.61

Pandas — Window Function ROW_NUMBER()

Here rank(..., method='first') emulates ROW_NUMBER() within each symbol partition. The output proves ranking restarts per symbol and orders rows by descending volume.

ohlcv['vol_rank'] = ohlcv.groupby('symbol')['volume'].rank(ascending=False, method='first').astype(int)
ohlcv[ohlcv.vol_rank == 1].sort_values('volume', ascending=False)[['symbol', 'date', 'volume']].head(5)
symboldatevolume
31077ISP.MI2023-08-08376391539
10782SAN.MC2021-10-20367211467
22666BBVA.MC2021-09-17228528294
46685NDA-FI.HE2022-09-16140675854
33211PRX.AS2021-08-17114772834

Polars — Window Function ROW_NUMBER()

Polars assigns the same per-symbol ranking through expressions rather than a mutable helper column. The output shows the generated row numbers beside the source rows.

pldf.with_columns(
    pl.col('volume').rank(descending=True).over('symbol').alias('vol_rank')
).filter(pl.col('vol_rank') == 1).sort('volume', descending=True).select('symbol', 'date', 'volume').head(5)
symboldatevolume
strdatei64
"ISP.MI"2023-08-08376391539
"SAN.MC"2021-10-20367211467
"BBVA.MC"2021-09-17228528294
"NDA-FI.HE"2022-09-16140675854
"PRX.AS"2021-08-17114772834

Pandas — Window Function LEAD()

This looks ahead to the next trading day and measures the calendar gap between rows. The output proves weekends and holidays appear as multi-day jumps even though market rows remain consecutive.

asml = ohlcv[ohlcv.symbol == 'ASML.AS'].sort_values('date').copy()
asml['next_date'] = asml['date'].shift(-1)
asml['gap_days'] = (pd.to_datetime(asml['next_date']) - pd.to_datetime(asml['date'])).dt.days
asml[asml.gap_days > 3][['date', 'next_date', 'gap_days']].sort_values('gap_days', ascending=False).head(5)
datenext_dategap_days
632021-04-012021-04-065.0
3312022-04-142022-04-195.0
5832023-04-062023-04-115.0
7662023-12-222023-12-275.0
11012025-04-172025-04-225.0

Polars — Window Function LEAD()

The Polars version computes the same next-date and gap calculation on the ordered symbol slice. The output shows the identical five-day holiday/weekend gaps as preserved notebook evidence.

pldf.filter(pl.col('symbol') == 'ASML.AS').sort('date').with_columns(
    pl.col('date').shift(-1).over('symbol').alias('next_date')
).with_columns(
    (pl.col('next_date') - pl.col('date')).dt.total_days().alias('gap_days')
).filter(pl.col('gap_days') > 3).sort('gap_days', descending=True).select('date', 'next_date', 'gap_days').head(5)
datenext_dategap_days
datedatei64
2021-04-012021-04-065
2022-04-142022-04-195
2023-04-062023-04-115
2023-12-222023-12-275
2024-03-282024-04-025

Joins

These examples move from aggregated price summaries into cross-table analytics by merging the OHLCV snapshot with the score snapshot.

Pandas — JOIN

This merges average close per symbol with the score table. Because scores contains multiple scoring dates per symbol inside the preserved snapshot, the output intentionally shows repeated join matches rather than a deduplicated dimension table.

avg_df = ohlcv.groupby('symbol')['close'].mean().round(2).reset_index(name='avg_close')
avg_df.merge(scores[['symbol', 'sector', 'composite_rank']], on='symbol').sort_values('composite_rank').head(5)
symbolavg_closesectorcomposite_rank
44BNP.PA60.94Financial Services1
43BNP.PA60.94Financial Services1
42BNP.PA60.94Financial Services1
60DTE.DE22.43Communication Services2
59DTE.DE22.43Communication Services2

Polars — JOIN

Polars performs the same join after converting the score slice into a Polars DataFrame. The repeated rows in the output confirm the same join cardinality as the pandas example.

pl_avg = pldf.group_by('symbol').agg(pl.col('close').mean().alias('avg_close'))
pl_scores = pl.DataFrame({
    'symbol': scores['symbol'].tolist(),
    'sector': scores['sector'].tolist(),
    'composite_rank': scores['composite_rank'].tolist(),
})
pl_avg.join(pl_scores, on='symbol').sort('composite_rank').head(5)
symbolavg_closesectorcomposite_rank
strf64stri64
"BNP.PA"60.937712"Financial Services"1
"BNP.PA"60.937712"Financial Services"1
"BNP.PA"60.937712"Financial Services"1
"DTE.DE"22.430097"Communication Services"2
"DTE.DE"22.430097"Communication Services"2

Pandas — STDEV()

This computes annualized volatility from percentage returns per symbol. The output ranks symbols by realized volatility, with the most volatile names at the top.

returns = ohlcv.sort_values(['symbol', 'date']).groupby('symbol')['close'].pct_change()
vol = returns.groupby(ohlcv['symbol']).std() * np.sqrt(252) * 100
vol.sort_values(ascending=False).head(10).round(2).to_frame('annual_vol_%')
annual_vol_%
symbol
ADYEN.AS50.30
ENR.DE50.05
RHM.DE40.85
PRX.AS39.72
ARGX.BR39.31
ASML.AS37.62
IFX.DE37.25
UCG.MI35.55
VOW.DE35.51
ADS.DE34.37

Polars — STDEV()

Polars derives the same annualized standard deviation from per-symbol returns. The output confirms the same high-volatility symbols appear first.

pldf.sort('symbol', 'date').with_columns(
    pl.col('close').pct_change().over('symbol').alias('ret')
).group_by('symbol').agg(
    (pl.col('ret').std() * (252 ** 0.5) * 100).alias('annual_vol_%')
).sort('annual_vol_%', descending=True).head(10)
symbolannual_vol_%
strf64
"ADYEN.AS"50.295865
"ENR.DE"50.045128
"RHM.DE"40.84623
"PRX.AS"39.715469
"ARGX.BR"39.305925
"ASML.AS"37.624556
"IFX.DE"37.249051
"UCG.MI"35.550249
"VOW.DE"35.509649
"ADS.DE"34.372174

Data modification patterns

These examples mirror insert-, update-, delete-, and drop-style transformations without writing back to storage. Each one returns a transformed frame so the effect is visible immediately in the preserved output.

Pandas — Add rows

pd.concat() appends a synthetic one-row DataFrame to the existing snapshot. The output proves the new TEST.XX row lands at the bottom and leaves missing derived columns such as vol_rank empty.

new_row = pd.DataFrame([{'symbol': 'TEST.XX', 'date': '2025-01-01', 'open': 100, 'high': 105,
    'low': 95, 'close': 102, 'adj_close': 102, 'volume': 50000}])
pd.concat([ohlcv, new_row], ignore_index=True).tail(3)
symboldateopenhighlowcloseadj_closevolumevol_rank
66353WKL.AS2026-03-1267.00067.5466.2867.3267.322103791312.0
66354DSY.PA2026-03-1218.07518.3918.0218.3718.374344171327.0
66355TEST.XX2025-01-01100.000105.0095.00102.00102.0050000NaN

Polars — Add rows

vstack() appends a schema-compatible row built from an existing template. The output shows why that template matters: fields you do not override, such as id, date, and adj_close, are inherited from the source row.

new_row = pldf.head(1).with_columns(
    pl.lit('TEST.XX').alias('symbol'), pl.lit(102.0).alias('close'), pl.lit(50000).cast(pl.Int64).alias('volume'))
pldf.vstack(new_row).tail(3)
idsymboldateopenhighlowcloseadj_closevolumedividendsstock_splitsis_filled
i64strdatef64f64f64f64f64i64f64f64bool
66877"WKL.AS"2026-03-1167.569.667.0267.2267.2211425310.00.0false
66929"WKL.AS"2026-03-1267.067.5466.2867.3267.322103790.00.0false
21160"TEST.XX"2021-01-0458.1558.8556.78102.053.5761500000.00.0false

Pandas — Update column

This recalculates adj_close for one symbol with assign() and returns the modified slice. The output proves the new values are computed from the current close column instead of mutating the base frame in place.

ohlcv[ohlcv.symbol == 'ASML.AS'].assign(adj_close=lambda d: d['close'] * 1.05)[['symbol', 'date', 'adj_close']].head(5)
symboldateadj_close
0ASML.AS2021-01-04426.5625
1ASML.AS2021-01-05427.2450
2ASML.AS2021-01-06422.9925
3ASML.AS2021-01-07424.0950
4ASML.AS2021-01-08436.8525

Polars — Update column

The Polars version overwrites adj_close in the returned expression result. The output shows the same recalculated values for the first ASML rows.

pldf.filter(pl.col('symbol') == 'ASML.AS').with_columns(
    (pl.col('close') * 1.05).alias('adj_close')
).select('symbol', 'date', 'adj_close').head(5)
symboldateadj_close
strdatef64
"ASML.AS"2021-01-04426.5625
"ASML.AS"2021-01-05427.245
"ASML.AS"2021-01-06422.9925
"ASML.AS"2021-01-07424.095
"ASML.AS"2021-01-08436.8525

Pandas — Delete rows

This removes ASML rows with a negated boolean mask and compares the row counts. The text output proves the filter drops exactly the ASML slice from the snapshot.

filtered = ohlcv[ohlcv.symbol != 'ASML.AS']
print(f"  {len(ohlcv)} - ASML rows = {len(filtered)} remaining")
66355 - ASML rows = 65024 remaining

Polars — Delete rows

Polars expresses the same delete-style operation as a filter that keeps every other symbol. The output confirms the remaining row count matches the pandas result.

filtered = pldf.filter(pl.col('symbol') != 'ASML.AS')
print(f"  {pldf.height} - ASML rows = {filtered.height} remaining")
66355 - ASML rows = 65024 remaining

Pandas — Drop column

drop(columns=...) removes housekeeping fields from the displayed frame. The output proves the requested columns are gone while the rest of the schema stays intact.

ohlcv.drop(columns=['dividends', 'stock_splits', 'is_filled'], errors='ignore').head(3)
symboldateopenhighlowcloseadj_closevolumevol_rank
0ASML.AS2021-01-04404.00411.00402.25406.25387.7090789502392
1ASML.AS2021-01-05406.55412.05401.15406.90388.3294798787381
2ASML.AS2021-01-06406.80407.20399.20402.85384.4644875711276

Polars — Drop column

Polars drops the same housekeeping columns with a positional expression-style call. The output shows the frame reduced to the retained nine columns.

pldf.drop('dividends', 'stock_splits', 'is_filled').head(3)
idsymboldateopenhighlowcloseadj_closevolume
i64strdatef64f64f64f64f64i64
21160"ABI.BR"2021-01-0458.1558.8556.7857.2153.57611513937
21161"ABI.BR"2021-01-0556.957.9856.7557.1853.5481382722
21162"ABI.BR"2021-01-0657.9658.9457.3958.7755.0371370204

Pandas vs Polars — Comparison Matrix

FeaturePandasPolars
EngineNumPy-backed DataFrame library with C/Cython kernelsRust query engine over Arrow-style columnar data
Memory modelMutable; copies depend on operation and settingsImmutable, expression-oriented columns
IndexOptional row index with .loc[] and .iloc[]No row-label index; positional rows plus expressions
Evaluation modelEager DataFrame operationsEager frames plus optional lazy query plans via .lazy()
ParallelismSome vectorized kernels release the GIL, but many workflows remain effectively single-threadedMultithreaded execution for many operations by default
String handlingobject, string, or Arrow-backed string dtypes depending on configurationArrow UTF-8 strings by default
Missing valuesNaN, None, and pd.NA depending on dtypenull as a first-class value across dtypes
GroupBySplit-apply-combine APIExpression-based grouped aggregations
Window functionsshift(), rolling(), rank(), transform()over() expressions plus rolling/window helpers
Joins.merge(on=, how=).join(on=, how=)
SQL supportread_sql*() via SQLAlchemy / DBAPI connectorsSQL surface via SQLContext / pl.sql, with expressions as the primary API
Large-file scansChunked reads at the IO boundary; in-memory work stays eagerLazy scans such as scan_parquet() can reduce memory and push filters earlier
EcosystemDefault target for many notebook, plotting, and ML librariesGrowing ecosystem with .to_pandas() as a common bridge
Learning curveLower if you already know notebook-style pandasHigher until the expression API becomes familiar

When to use Pandas

  • Notebook exploration and plotting — pandas remains the default target for many examples, charting libraries, and ad hoc analysis flows.
  • ML and statistics interop — scikit-learn, statsmodels, and many feature-engineering utilities still expect pandas objects directly.
  • Index-heavy workflows — labeled time-series and multi-index operations are more natural when row indices are part of the design.
  • Existing pandas codebases — if the pipeline already works and is not bottlenecked, pandas is usually the cheaper operational choice.

When to use Polars

  • Large analytical workloads — Polars often shines when wide or tall datasets can stay in vectorized column expressions.
  • Lazy ETL pipelines.lazy() plus scan APIs help push filters and projections earlier in the plan.
  • Memory-sensitive batch transforms — immutable columnar execution reduces some of the copy and Python-object overhead common in pandas workflows.
  • Arrow- and Parquet-centered stacks — Polars fits naturally when the rest of the pipeline already speaks columnar formats.

Operational constraints and diagnostics

Typing and iterator rules

Optional, Callable, and list[int] follow different syntax eras

list[int], dict[str, int], and similar container annotations are built-in syntax in Python 3.9+, but Optional and Callable still come from typing unless you adopt newer alternatives such as str | None. If you need compatibility with Python versions older than 3.9, use typing.List[...] or from __future__ import annotations instead of assuming the built-in generic syntax exists.

Missing Generic[T] and mypy assignment errors usually have the same root cause

When mypy reports an incompatible assignment in generic code, the problem is often that the type variable stopped propagating through the API. The usual fixes are to inherit from Generic[T], annotate the internal storage with the same T, and then correct whichever assignment or return path drifted away from the declared type.

Iterator exhaustion and groupby() ordering are consumption problems

The generator and map() examples above show why iterator pipelines are single-pass: once consumed, they are empty. itertools.groupby() has a related constraint in a different dimension: it only groups consecutive equal keys, so sort first when you want SQL-style grouping, or use defaultdict(list) when a one-pass accumulation is clearer.

Pandas constraints

Chained assignment and nullable integer handling

SettingWithCopyWarning exists because df[mask][col] = ... may target a temporary slice instead of the original frame. Use .loc[...] for in-place updates or .copy() when you want an isolated slice. For integer columns that may hold missing values later, prefer nullable dtypes such as pd.Int64Dtype() so pandas does not silently promote the whole column to float64.

Row-wise pandas APIs and timestamp edges

Row-wise helpers such as .apply(axis=1) and .iterrows() materialize Python objects and usually cost more memory and CPU than vectorized expressions. Also keep temporal precision in mind: pandas timestamps use nanosecond semantics with an upper bound around year 2262, so cross-library time handling can diverge once you move between pandas, Arrow, and Polars.

Polars constraints

No .loc[] and no materialized data until .collect()

The subsetting examples above show the first implication of Polars having no row-label index: row filters become explicit expressions such as .filter(pl.col("symbol") == "ASML.AS"). The second implication appears in lazy mode: a LazyFrame is only a plan until .collect() runs it, so inspect or export only after materialization.

Schema inspection and pandas handoff still matter

Polars preserves schema predictably, but joins, appends, and interop boundaries are still worth checking explicitly, especially when you build synthetic rows from templates as in the vstack() example above. When a downstream visualization or ML library expects pandas, convert at the boundary with .to_pandas() rather than bouncing back and forth mid-pipeline.