xianyunshi

polars learning notes: lazy scanning, streaming collect, and a 39GB read that crashed

thirteen things that actually stuck from three practice notebooks, and the eager read that undid all of them at the one file where it mattered

topics
  • polars expressions vs values
  • list column mini-expressions
  • lazy evaluation and predicate pushdown
  • struct field unpacking
  • aggregation grain
  • window functions vs group_by
  • log-return compounding
  • row-group statistics
  • eager read kernel crash
  • lazy streaming collect

Closer to a personal reference sheet, for lookup or re-learning, than a piece meant to be read cold. Sorry if it doesn’t feel too clear on a cold read. These are things I learned from my own practice notebooks. Dataset: a 71.8 million row financial sentence corpus.


expr = pl.col("sentenceID").unique()
print("Expression object:", expr)          # prints a plan node, not data

result = df.select([expr])                 # now it's data
unique_sentence_ids = df.get_column("sentenceID").unique().to_list()   # eager, immediate, a plain list

1. pl.col(...) is a plan, not a value, until something evaluates it. Polars has two parallel worlds that look similar and aren’t. df.get_column(...) walks into the eager world and hands back a concrete column right away. Call .to_list() on it and you get a plain Python list. pl.col(...) walks into the expression world instead. It builds a small unevaluated tree that describes what to compute, and it only becomes data once you hand it to select, with_columns, or .agg(...). Printing the bare expression prints the plan, not the answer. That’s the moment that trips you up the first time, because nothing in the syntax warns you which world you’re in. Every later pattern in this list (window functions, struct unpacking, lazy scans) is really just more things you can put inside this expression tree before evaluating it once.


df.with_columns(
    y = pl.col.x + 3,
    x_max = pl.col("x").list.max(),
    x_sum = pl.col("x").list.sum()
).with_columns(
    z2 = pl.col("x").list.eval((pl.element() >= 2) & (pl.element() <= 4))
)
tickers_norm = (
    pl.col("tickers")
      .list.eval(pl.element().str.to_uppercase())   # a string method, run *inside* every list cell
      .list.unique().list.sort()
)

2. A list column isn’t something you iterate. It’s a mini expression scope. .list.max() and .list.sum() are what you’d expect: one aggregate number per row. .list.eval() is the interesting one. It takes an expression written in terms of pl.element(), the placeholder for “the current item inside this list,” and runs it vectorized against every element of every list in the column, no Python for loop unpacking anything. The first example runs a numeric predicate elementwise (>= 2 & <= 4). The second runs a string method elementwise (.str.to_uppercase()). Worth keeping: .list.eval() isn’t a special tool for numbers. It’s the entire expression language, just scoped one level down into the list.


exploded = toy.select(["docID", "tickers"]).explode("tickers")
result1 = (
    exploded.group_by("docID")
    .agg(pl.col("tickers").unique().sort().alias("merged_tickers"))
)
result = (
    toy.group_by("docID")
    .agg(pl.col("tickers").flatten())          # no explode step at all, it just works
)
row_concat = toy.select([
    pl.concat_list(["tickers", "exchanges", "alt"]).alias("combined")   # merges across COLUMNS, same row
])

3. “Merge these lists” splits into two different questions, and each has its own function. Merging lists down a column, across rows in a group (three filings’ ticker lists into one) is what explode, then group_by, then re-aggregate does. Turns out to be unnecessary ceremony, because .flatten() inside .agg(...) does the same collapse in one line, no intermediate row-per-element table needed. Merging lists across columns, within the same row (this row’s tickers plus this row’s exchanges into one combined list) is a completely different operation, pl.concat_list. Confusing the two is an easy mistake, because both start from “I have several lists and I want one list.”


df.with_columns(
    pl.when(pl.col("x").str.starts_with("foo"))
      .then(pl.col(pl.String).str.to_uppercase())
      .otherwise(pl.col(pl.String))
)
kpi_sentences.filter(
    pl.col("likely_kpi") | pl.col("has_numbers")
).filter(
    pl.col("section").is_in([9, 10])
)

4. Conditional logic in Polars is either a rewrite or a filter, and they’re not interchangeable. pl.when().then().otherwise() rewrites values. Every row survives, some just get a different value written into them (here: uppercase every string column, but only on rows whose x starts with "foo"). .filter(A | B) removes rows. Nothing gets rewritten, some rows just stop existing in the output. Both are boolean logic over columns, both avoid a Python-level branch. One shrinks the table and one doesn’t. Picking the wrong one is a subtle bug, not an error message.


print(df.schema.get("returns"))
print(df.select(pl.col("returns")).head(2))     # look before you unpack
df_flat = df.with_columns([
    pl.col("returns").struct.field("1d").struct.field("ret").alias("ret_1d"),
    pl.col("returns").struct.field("5d").struct.field("ret").alias("ret_5d"),
    pl.col("returns").struct.field("30d").struct.field("ret").alias("ret_30d"),
])

5. Real filing data doesn’t arrive flat. SEC returns ship as a nested struct. returns isn’t a number column, it’s a struct with three named sub-structs (1d, 5d, 30d), each holding its own ret field, like returns.5d.ret, three levels deep. .struct.field(...) chains exactly like a path: one call per level. The habit worth keeping is the first snippet, not the second. Check df.schema.get(...) or peek at one row before writing the unpacking chain. Guessing field names on a nested struct wastes more time than the two-second look.


df_year = df.with_columns(
    pl.col("reportDate").str.strptime(pl.Date, strict=False).dt.year().alias("fiscal_year")
)
comp_rollup = df_year.filter(
    (pl.col("fiscal_year") > 2014) & (pl.col("section").is_in(TOP10))
)
kpi_sentences = (
    df.lazy()
    .filter(pl.col("reportDate").str.slice(0, 4).is_in(TARGET_YEARS))   # cheaper: no real date parse
    .filter(pl.col("section").is_in(KPI_SECTIONS))
)

6. The column you want to filter on sometimes doesn’t exist yet. You derive it once, then treat it like any other column. reportDate arrives as a string. fiscal_year doesn’t exist until strptime plus .dt.year() make it exist. Worth noticing: two different notebooks solve “get the year” two different ways. One is a real typed Date parse (correct, handles malformed dates via strict=False, costs an actual parse). The other is a raw str.slice(0, 4) on the assumption the field always starts with a 4-digit year (cheaper, and silently wrong the moment that assumption breaks). Neither is “the” right answer. It’s a real correctness versus cost tradeoff that shows up again later at a much bigger scale.


DATA_PATH = Path("../data/exports/sec_filings_small_full.parquet")
df = pl.read_parquet(DATA_PATH)     # eager, loads now
lf = pl.scan_parquet(DATA_PATH)     # lazy, loads nothing yet, just describes a plan
lf_base = (
    pl.scan_parquet(DATA_PATH)
    .with_columns(...)
    .filter(...)
)
lf_cached = lf_base.cache()          # pin one filtered slice, branch multiple aggregations off it

7. Going lazy is a decision you check, not a habit you trust. pl.scan_parquet instead of pl.read_parquet costs nothing to write and means something completely different. The file isn’t read until something downstream forces it. Everything between the scan and the final .collect() is a plan the query optimizer gets to rewrite: pushing filters down to the file reader, dropping unread columns, before a single row moves. .cache() on a lazy frame is the companion move. Once you’ve built one filtered or derived slice you plan to reuse for several different aggregations, cache it so the shared filtering work happens once. My own note from around this point says to check with .explain() that only the needed columns and rows are actually being scanned, and that doing this catches real speedups. I never wrote down a measured number for that, so treat it as a habit worth keeping, not a benchmark.


docs = (
    df_year
    .group_by("docID")
    .agg([
        pl.first("cik").alias("cik"),
        pl.first("fiscal_year").alias("fiscal_year"),
        pl.n_unique("section").alias("n_sections_doc"),
        pl.count().alias("n_sentences_doc"),
    ])
)
company_year = (
    docs
    .group_by(["cik", "name", "fiscal_year"])
    .agg([
        pl.n_unique("docID").alias("n_docs"),
        pl.sum("n_sentences_doc").alias("n_sentences"),
    ])
)

8. The “grain” of a table has to be nailed down before you aggregate across it, or the higher-up numbers quietly double count. The raw table is one row per sentence. Rolling straight from sentences to a company-year summary in one aggregation would double count anything that varies per sentence but is really a per-filing fact. So it happens in two explicit steps. First, collapse to one row per docID (a real filing), carrying forward the doc-level facts with pl.first(...) since they’re constant within a filing. Only then roll that table up to company-year. Two aggregations instead of one looks like more code. It’s actually the thing that keeps the second aggregation’s counts honest.


lf_doc_returns_long = (
    lf_docs
    .select("docID", "cik", "name", "fiscal_year", "ret_1d", "ret_5d", "ret_30d")
    .unpivot(
        index=["docID", "cik", "name", "fiscal_year"],
        on=["ret_1d", "ret_5d", "ret_30d"],
        variable_name="horizon",
        value_name="ret"
    )
    .filter(pl.col("ret").is_not_null())
    .with_columns(
        horizon = pl.col("horizon").str.replace("^ret_", "", literal=False),
        ret_safe = pl.col("ret").clip(lower_bound=-0.9999),
        # if the Polars build lacks Expr.clip(), the fallback written down at the time was:
        # ret_safe = pl.when(pl.col("ret") < -0.9999).then(-0.9999).otherwise(pl.col("ret"))
    )
)

9. Three parallel columns become one column with a label, so one aggregation replaces three. ret_1d, ret_5d, ret_30d are the same kind of fact at three horizons. Exactly the shape .unpivot() is for. index names what stays fixed per row, on names the columns being folded down into two new ones: horizon (which column it came from) and ret (the value). One group_by(["cik","horizon"]) downstream now covers all three horizons instead of three near-identical blocks of aggregation code. The ret_safe clip is a small piece of domain hygiene riding along. A return below minus 100% would make the next step’s log() blow up, so it gets floored first, with a plain when/then fallback written down in a comment in case .clip() isn’t available in a given Polars build. Worth keeping as a pattern in general.


comp_ret = ((pl.col("ret_safe") + 1).log().sum()).exp() - 1     # compounded return, one group, one year
log1p_ret = (pl.col("comp_ret") + 1).log()
cagr           = pl.col("log1p_ret").mean().exp() - 1            # across years: mean, not sum, then back out
total_comp_ret = pl.col("log1p_ret").sum().exp() - 1

10. Averaging returns arithmetically is wrong. The fix is log-sum-exp, and it’s just expression chaining. Compounding is multiplicative, (1+r1)(1+r2)..., and a plain mean of the r’s overstates it. The standard fix converts each return to log(1+r), sums them (or means them, for an annualized CAGR across years), and exponentiates back. sum for total compounded return across docs in one year, mean for CAGR across years, same log1p groundwork underneath both. Nothing about this needs a special finance function. It composes with group_by().agg() like any other expression. The “hard” quant technique and the “boring” aggregation machinery are the same tool.


df_ranked = df.with_columns([
    (pl.int_range(0, pl.len()).over("store") + 1).alias("row_num"),
    pl.col("sales").rank().over("store").alias("rank"),
    (pl.col("sales") == pl.col("sales").max().over("store")).alias("is_best_day"),
])
lf_first3_per_doc = (
    lf_base
    .sort(["docID", "sentenceCount"])
    .with_columns([
        pl.int_range(0, pl.len()).over("docID").alias("pos_in_doc"),
        pl.int_range(0, pl.len()).over(["docID", "section"]).alias("pos_in_sect"),
    ])
)

11. group_by collapses a group to one row. .over(...) keeps every row and stamps the group fact back onto each one. pl.col("sales").sum().over("store") computes the same total a group_by("store").agg(sum) would, but the toy dataframe still has 6 rows afterward, not 2. Every row now carries its store’s total alongside it, which is what makes per-row things like rank, row-number, and “is this the best day for this store” possible without a join back to a collapsed table. The real version does the same thing with a compound key. .over(["docID", "section"]) numbers each sentence’s position within its own section of its own filing, a compound window key instead of a single one. That’s the version that later feeds into “is this the first sentence of a section” flags.


company_horizon_rollup = (
    lf_yearly
    .group_by(["cik", "name", "horizon"])
    .agg(
        _best  = pl.struct(["fiscal_year", "mean_ret"]).sort_by("mean_ret", descending=True).first(),
        _worst = pl.struct(["fiscal_year", "mean_ret"]).sort_by("mean_ret", descending=False).first(),
    )
    .with_columns(
        best_year  = pl.col("_best").struct.field("fiscal_year"),
        best_ret   = pl.col("_best").struct.field("mean_ret"),
        worst_year = pl.col("_worst").struct.field("fiscal_year"),
    )
)

12. “Argmax, but I also want the other columns from that row” doesn’t have a built-in. You pack a struct, sort it, and unpack it back. .max() inside an aggregation gives you the best value, and nothing else about the row it came from. Wrapping the columns you actually need into pl.struct([...]), sorting that list of structs by the field you care about, and taking .first() (or .last(), or flipping descending) gets you the whole row’s worth of context back. Here, which fiscal year was the best and worst for a company, not just the number itself. It’s a pattern, not a one-off: pack, sort_by, first, unpack. Generalizes to any “best row in the group, with its context” problem.


kpi_sentences = (
    df.lazy()
    .filter(pl.col("reportDate").str.slice(0, 4).is_in(TARGET_YEARS))
    .filter(pl.col("section").is_in(KPI_SECTIONS))
)
s9_kpi_df = (
    kpi_sentences
    .filter(pl.col("likely_kpi") | pl.col("has_numbers"))
    .filter(pl.col("section").is_in([9, 10]))
    .sort(["year", "section", "name"])
    .head(400)
)

13. By the last notebook, the tools stop being demonstrations and start being a real narrowing pipeline. Nothing here is a new technique. It’s .lazy(), .filter(), .sort(), .head(), all already covered. What changed is the target: from “show that this works” to “get from 600K+ sentences down to a small KPI-likely candidate set.” Each filter is independently readable (right years, right sections, looks numeric-or-KPI-ish, cap the result) rather than one dense boolean condition. That’s the actual skill: chaining simple filters instead of writing one clever one.

where the practice habits met the real file

Every one of the thirteen things above ran against sec_filings_small_full.parquet. Small enough that eager versus lazy barely showed up as a difference, filters pushed down cheaply, and .explain() came back clean. It was a sandbox, and a good one, but it never once tested the thing it was implicitly training for.

The real corpus is 71,866,962 rows. Converting the raw HuggingFace shards to a single Polars DataFrame was the very first step, before any of the thirteen patterns even applied, and it put 39.03GB in memory. That got written down to a 1.53GB Parquet file (28.7x compression). The next real line of code against that file was df_large = pl.read_parquet("sec_filings_large_full.parquet"). Eager, unscanned, no .lazy() in sight. The kernel crashed with no traceback. A Windows access violation, not a catchable Python exception. That happened after a dozen cells of correctly reaching for .scan_parquet() on the small file. The habit was there, and it still didn’t fire on the one file where it mattered.

The fix exists, but only as a rule, not as a rerun. platform_core_contract.py already says it plainly: kernel crashes mean switch to lazy Polars, scan_parquet, collect at the end. It works elsewhere in the project, at the embeddings scale, 614,787 rows, roughly 2GB. It was never pointed back at the 71.8M-row file that actually broke. The bulk work at that scale went to DuckDB instead. So whether lazy Polars actually holds at 71.8M rows is still open. Nobody ever ran it again on the file that raised the question in the first place.

things I think I forgot to write down, worth adding

The one sentence that ties all thirteen points to the crash, and I don’t think I ever stated it this plainly: lazy defers work, it does not shrink the result. .scan_parquet() buys you predicate and projection pushdown, real savings. But if the query you eventually .collect() still has to materialize the full wide result (every column, every row, no filter having survived to the end), laziness bought you nothing. That line, found almost verbatim in my own TechNotes_MemoryExp_Handling.md, is the actual thesis connecting the twelve clean examples to the one crash. Deserves to be written where I’ll see it again, not buried in a debugging note.

A quiet API drift worth flagging for future me: the working code across this project calls .collect(streaming=True). Current Polars docs show the same feature as .collect(engine="streaming"). Nothing says the old form is deprecated, but if I ever copy this code into something new, copy the current spelling, not the one that already works here.

Row-group size is the same “grain” idea from point 8, one layer down. I set the DuckDB row group size parameter to 100000. This is what happened: smaller row groups give finer grained min/max stats, so a filter has more chances to skip a group entirely, at some cost in footer metadata per group. The source file’s own row groups average 261,334 rows per group, much bigger than what I chose.

Open experiment, still todo: does pl.scan_parquet(large_file).filter(...).collect(engine="streaming") handle the 71.8M row file that crashed the kernel? DuckDB already solved the operational problem. This would answer the Polars question specifically.