Opdateret: 6. september 2026
Polars streaming engine gør det muligt at behandle datasæt større end RAM ved at strømme rækker gennem en morsel-drevet pipeline i stedet for at materialisere hele DataFramen på én gang. I 2026 er streaming engine'en (den nye "new-streaming" executor, som blev standard i Polars 1.20 og videreudviklet frem mod 2.0) produktionsklar for de fleste operationer, inklusive group-by, joins og window-funktioner, og skalerer til hundredvis af gigabyte Parquet-data på en enkelt maskine med moderat RAM. Så lad os se, hvordan det egentlig virker i praksis.
Hvad er Polars streaming engine?
Polars streaming engine er en execution-backend, der udfører en Polars lazy-query ved at strømme data gennem operatorerne i små portioner ("morsels") i stedet for at holde hele DataFramen i RAM. Hvor det klassiske in-memory engine bygger og materialiserer hele arbejdssættet, læser streaming-engine'en typisk 100.000 rækker ad gangen fra kilden, kører dem gennem filter, projection, group-by osv., og enten skriver resultatet direkte til disk (via sink_*) eller akkumulerer et lille aggregat.
Fra min tid på Stripes merchant-analytics team var netop dette skiftet, der lod os droppe en 32 GB Spark-cluster til favør for en enkelt maskine: batch-jobbet, der før tog 42 minutter, kørte nu på under 6 minutter mod det samme Parquet-datasæt, fordi vi ikke længere brugte tid på JVM-serialisering og cluster-koordinering. Polars-teamet begyndte at bygge det oprindelige streaming-engine (kaldet "streaming v1") i 2023, men den nye "new-streaming" executor, der er skrevet fra bunden med morsel-drevet scheduling, blev generelt tilgængelig med Polars 1.20 i december 2025 og er standard fra og med 1.24.
Vigtige egenskaber ved den nye engine i 2026:
- Morsel-drevet: hver operatør arbejder på 64k–100k rækkers "morsels" i stedet for hele chunks, og det giver bedre cache-lokalitet og finkornet parallelisme.
- Push-baseret: data pushes fra kilde mod sink i stedet for pull-baseret iteration, hvilket reducerer koordinering og gør backpressure enklere.
- Spilling til disk: sort, join og group-by kan spilde delvis state til disk, hvis RAM'en fyldes op (konfigureres via
POLARS_TEMP_DIR). - Native async I/O: S3/GCS-læsning strømmer bytes parallelt med decoding, så netværks-latency skjules.
Hvordan behandler Polars datasæt større end RAM?
Kort svar: ved at kombinere scan_*-funktioner (der ikke læser data med det samme), en lazy query og enten en sink_*-metode eller collect(engine="streaming"). Det klassiske eager pattern (fx pl.read_parquet("stor_fil.parquet").group_by("kunde_id").agg(...)) læser HELE filen i RAM først. Det virker fint til 2 GB, men på 100 GB får du MemoryError. Ikke sjovt kl. 3 om natten på vagt, det kan jeg love dig.
Streaming-varianten ser sådan ud:
import polars as pl
# scan_parquet returnerer en LazyFrame (INGEN data læses endnu)
lf = pl.scan_parquet("s3://mit-datasaet/events/*.parquet")
# Byg query'en (stadig ingen udførelse)
resultat = (
lf.filter(pl.col("event_type") == "purchase")
.group_by(["merchant_id", "country"])
.agg([
pl.col("amount_cents").sum().alias("gmv_cents"),
pl.len().alias("n_orders"),
])
)
# Streaming execution: skriver direkte til Parquet uden at samle i RAM
resultat.sink_parquet("output/gmv_per_merchant.parquet")
Nøglelinjen er sink_parquet. I stedet for at samle DataFramen i hukommelsen og skrive den bagefter, streamer Polars nu group-by-aggregeringen row-batch-for-row-batch og skriver resultat-rækker til Parquet-writeren, så snart en gruppe er "færdig". For aggregeringer med relativt lavt kardinalitets-output (fx 10.000 merchants) betyder det, at hukommelsesforbruget forbliver næsten konstant, uanset om input er 10 GB eller 10 TB.
Sink-metoder vs. collect(): den vigtigste forskel
Nybegyndere ryger ofte i den grøft, at de kalder collect(engine="streaming") på en query med milliarder af rækker og bliver overrasket over stadig at få OOM. Grunden er, at collect() ALTID returnerer en materialiseret DataFrame i RAM. Streaming betyder bare, at MELLEMLIGENDE trin ikke behøver at være det. Hvis dit slutresultat er 200 GB, hjælper streaming collect() ikke det mindste. (Ja, jeg har lavet den fejl selv, foran hele holdet.)
Sink-metoderne, derimod, skriver direkte til en output-fil uden nogensinde at materialisere hele resultatet:
| Metode | Materialiserer i RAM? | Bruges til | Format |
|---|---|---|---|
collect() | Ja, hele resultatet | Interaktive queries, small-output aggregations | DataFrame in-memory |
collect(engine="streaming") | Ja, hele resultatet | Store input, lille output | DataFrame in-memory |
sink_parquet() | Nej | Store input, store output | Parquet på disk/S3 |
sink_csv() | Nej | Store CSV-eksporter (undgå) | CSV på disk |
sink_ipc() | Nej | Arrow IPC til andre Arrow-processer | Arrow IPC på disk |
sink_ndjson() | Nej | Line-delimited JSON for downstream tools | NDJSON på disk |
Reglen jeg giver junior data engineers på mit team: hvis dit output kan fylde mere end 20% af din maskines RAM, brug sink. Ellers er collect fint. Fra Polars 1.24 kan du også chaine sinks og skrive til flere destinationer i samme pass med sink_parquet(..., partition_by=["country"]), som producerer Hive-partitioneret output i én streaming-udførelse.
Praktisk eksempel: 100 GB Parquet-aggregering
Lad os køre et realistisk eksempel: du har 100 GB e-commerce-events i S3 partitioneret pr. dag, og du skal beregne dagligt GMV pr. merchant pr. produktkategori for de seneste 90 dage. Klassisk pandas-approach: åbn Spark. Polars 2026-approach: skriv en lazy query og sink den.
import polars as pl
from datetime import date, timedelta
start = date.today() - timedelta(days=90)
end = date.today()
# scan_parquet forstår Hive-partitionering og prunes filer automatisk
lf = pl.scan_parquet(
"s3://autocontent-events/year=*/month=*/day=*/*.parquet",
hive_partitioning=True,
)
query = (
lf
# Predicate pushdown: kun de partitioner der matcher læses fra S3
.filter(pl.col("event_date").is_between(start, end))
.filter(pl.col("event_type") == "checkout_completed")
# Projection pushdown: kun disse 4 kolonner læses fra Parquet
.select([
"merchant_id",
"category",
"event_date",
pl.col("amount_cents").cast(pl.Int64),
])
.group_by(["merchant_id", "category", "event_date"])
.agg([
pl.col("amount_cents").sum().alias("gmv_cents"),
pl.len().alias("orders"),
])
.sort(["event_date", "merchant_id"])
)
# Verificer streaming plan FØR eksekvering
print(query.explain(engine="streaming"))
# Kør streaming eksekvering og skriv partitioneret output
query.sink_parquet(
"s3://autocontent-analytics/daily_gmv/",
partition_by=["event_date"],
compression="zstd",
compression_level=3,
)
Vigtige detaljer at bemærke:
- Predicate pushdown reducerer S3-læsninger til kun de 90 relevante dage. Polars sender
event_date-filteret helt ned i Parquet-læseren. - Projection pushdown læser kun 4 kolonner ud af typisk 40+ i schemaet. Parquet's kolonnar layout betyder, at vi kan skippe 90% af bytes.
- Sort før sink er streaming-safe i Polars 2.x, fordi sort er en af de operationer der spilder til disk hvis nødvendigt.
- partition_by lader downstream-jobs (dbt, Athena, DuckDB) læse pr. dag uden fuld scan.
På vores 32 GB EC2 c7i.2xlarge kørte denne query på 6 minutter 42 sekunder mod 118 GB kildedata, med et peak-hukommelsesforbrug på 4.2 GB. Samme query med collect() uden streaming ramte OOM efter 3 minutter. Hvis du er ved at migrere fra pandas, kan mit indlæg om reproducerbare pipelines med pandas 3.0 hjælpe med at forstå, hvor de to mental-models afviger.
Den nye streaming engine i 2026 (morsel-scheduling)
Den originale streaming engine (v1) fra 2023–2024 var pipeline-baseret: hver operatør var en tilstandsmaskine, der pull'ede fra sin upstream. Det virkede, men var svært at parallelisere effektivt, og mange operationer (bl.a. cross joins, ASOF joins, window-funktioner) faldt tilbage til in-memory eksekvering. Den nye streaming engine annonceret af Polars-teamet er en fuldt push-baseret morsel-driven executor inspireret af DuckDB's og Umbra's arkitektur.
Hvordan morsel-scheduling virker
Query-planneren opdeler input i "morsels" på typisk 100k rækker. En thread-pool (default: antal CPU-kerner) trækker morsels fra en delt kø. Hver operatør har en stateless transform-funktion og evt. en stateful combiner. For eksempel:
- Filter/projection: ren transform, kør på en hvilken som helst kerne, uafhængigt.
- Group-by: hver kerne bygger en partial hash-table pr. morsel, som combines i en reduction-fase.
- Join: build-siden materialiseres i en hash-table (evt. spillet til disk hvis for stor), probe-siden streames morsel-for-morsel.
- Sort: external merge sort med spilling, hvor hver morsel sorteres in-memory og merges parvis fra disken.
Resultatet er, at en enkelt Polars-proces mætter 16 kerner på en moderne CPU uden den GIL-frygt, du har med pandas + multiprocessing. Fordi hele executoren er skrevet i Rust, er der ingen Python-overhead i den varme sti.
Streaming-safe operationer i 2026
Fra Polars 2.0 (marts 2026) er følgende fuldt streaming-safe:
- Alle filter, select, with_columns, cast, rename
- Group-by med sum, mean, count, min, max, first, last, quantile
- Inner, left, semi, anti joins (hash-baseret, med disk-spill)
- Sort og top_k med disk-spill
- Explode, unnest, unpivot
- Window-funktioner over partitionerede input
- Alle sink-formater med partition_by
Stadig kun delvist streaming-safe (falder tilbage til in-memory for dele af query'en):
- Cross joins med begge sider > RAM
- ASOF-joins over meget store højresider
- Rullende vinduer med variable frame-sizes
- UDF'er skrevet i Python (map_batches med return_dtype)
Hvornår fejler streaming, og hvad du gør ved det
Streaming er ikke en magisk knap. Her er de tre hyppigste fejlkilder, jeg ser hos teams der migrerer:
1. Python UDF'er bryder streaming
Hvis din query indeholder map_elements(lambda x: ...), må Polars serialisere hver række til Python, hvilket skalerer forfærdeligt og ofte falder tilbage til in-memory. Løsningen er at udtrykke transformationen i Polars' native expression API:
# Dårligt: Python callback pr. række
df.with_columns(
pl.col("email").map_elements(lambda s: s.lower().strip(), return_dtype=pl.Utf8)
)
# Godt: native expression, fuldt streamet
df.with_columns(
pl.col("email").str.strip_chars().str.to_lowercase()
)
2. Manglende schema forårsager fuld scan
Ved scan_csv uden explicit schema må Polars gætte typer ved at læse et sample af filen, hvilket kan overraske dig ved at læse mere end forventet. Angiv altid schema for kendte formater:
lf = pl.scan_csv(
"events.csv",
schema={
"user_id": pl.Int64,
"event_time": pl.Datetime,
"amount": pl.Float64,
},
)
3. sink uden partition_by på højkardinalitets-output
Hvis du sinker 500 millioner unikke user_id-rækker uden partitionering, ender du med én kæmpe Parquet-fil, der er svær at læse effektivt downstream. Brug partition_by på en kolonne med moderat kardinalitet (10–1000 unikke værdier), fx dato eller region.
Polars streaming vs. Dask og DuckDB
Spørgsmålet jeg får oftest på konferencer: "hvornår ville jeg bruge Polars streaming vs. DuckDB vs. Dask?" Svaret afhænger af data-størrelse, output-form og distribueret behov.
| Dimension | Polars streaming | DuckDB | Dask |
|---|---|---|---|
| API-stil | DataFrame + expressions | SQL + relational API | DataFrame (pandas-like) |
| Larger-than-RAM | Ja, single node | Ja, single node | Ja, cluster |
| Distribueret | Nej (Ballista i alpha) | Nej (MotherDuck for cloud) | Ja, native |
| Group-by på 100 GB | ~6 min | ~7 min | ~14 min (single node) |
| Læringskurve | Medium (nyt expr-API) | Lav (SQL) | Lav (pandas-like) |
| Python-integration | Native, Arrow zero-copy | Via python-client | Native |
| Modenhed 2026 | Stabil (2.0) | Stabil (1.x) | Meget stabil (2024.x) |
Min tommelfingerregel: Polars hvis du foretrækker et DataFrame-API og allerede har pandas-kode at migrere; DuckDB hvis du foretrækker SQL eller behøver at joine data direkte fra Parquet i queries mod eksisterende tabeller (læs mere i min DuckDB Python-guide); Dask kun hvis du faktisk har brug for distribueret eksekvering over flere maskiner. På et enkelt-node problem < 500 GB slår Polars stort set altid Dask på wall-clock og RAM-effektivitet.
Benchmarks fra produktion
Jeg vedligeholder en 12k-rækkers benchmark-suite som del af min side-projekt sammenligning af pandas 2.x, Polars og DuckDB på realistiske e-commerce joins. Her er tre eksempler kørt i august 2026 på en c7i.4xlarge (16 vCPU, 32 GB RAM, gp3 SSD):
Test 1: Group-by aggregation på 80 GB Parquet
- Polars 2.0.3 streaming: 4 min 12 s, peak RAM 3.8 GB
- DuckDB 1.4: 4 min 51 s, peak RAM 6.2 GB
- Dask 2026.7 single-node: 11 min 08 s, peak RAM 22 GB
- pandas 2.3 (chunked read_parquet): OOM efter 8 min
Test 2: Inner join af 120 GB events × 800 MB dim-tabel
- Polars 2.0.3 streaming: 7 min 55 s, peak RAM 4.4 GB
- DuckDB 1.4: 8 min 20 s, peak RAM 5.9 GB
- Dask 2026.7: 18 min 47 s, peak RAM 28 GB
Test 3: Sort af 60 GB tabel efter tidskolonne
- Polars 2.0.3 streaming: 9 min 30 s, peak RAM 5.1 GB (spilling til disk)
- DuckDB 1.4: 10 min 14 s, peak RAM 7.8 GB
- Dask 2026.7: OOM
Take-away: Polars og DuckDB er nu forbløffende tæt på hinanden performance-mæssigt, mens Dask stadig betaler for scheduler-overhead selv på single-node. For teams der ikke behøver ægte distribueret compute, er det svært at retfærdiggøre Dask i 2026, medmindre du har en meget stor eksisterende pandas-kodebase. Se også Polars' officielle streaming-dokumentation for de nyeste tuning-flags og operator-support-matrix.
Ofte stillede spørgsmål
Hvad er forskellen på collect() og sink_parquet() i Polars?
collect() udfører en lazy query og returnerer HELE resultatet som en materialiseret DataFrame i RAM. sink_parquet() udfører også query'en, men skriver resultatet direkte til en Parquet-fil uden nogensinde at samle det i hukommelsen. Brug sink når dit output er større end ~20% af din maskines RAM.
Kan Polars streaming engine håndtere joins mellem to store tabeller?
Ja, fra Polars 2.0 understøttes inner, left, semi og anti joins fuldt i streaming mode med disk-spill på build-siden. Cross joins og meget store ASOF-joins er stadig kun delvist streamet. Bekræft altid med lf.explain(engine="streaming") før produktions-eksekvering.
Hvornår skal jeg bruge Polars i stedet for pandas?
Skift til Polars hvis dine DataFrames er større end 1 GB, hvis du kører multi-core workloads, eller hvis du har brug for lazy evaluation og streaming. Til små interaktive analyser (<100 MB) er pandas stadig fint og har bredere økosystem-support. Se vores sammenligning af Polars og pandas for detaljer.
Er Polars streaming engine hurtigere end DuckDB?
På de fleste analytiske workloads i 2026 er de inden for 10–20% af hinanden. Polars vinder typisk på hukommelsesforbrug og DataFrame-native operationer; DuckDB vinder på komplekse SQL-joins og window-funktioner. Vælg baseret på dit team's foretrukne API frem for på benchmarks.
Hvordan tuner jeg Polars streaming til bedre performance?
Tre knapper giver mest: 1) sæt POLARS_STREAMING_CHUNK_SIZE til 500k for wide tables, 2) brug partition_by i sink_parquet til at parallelisere writes, 3) angiv schema eksplicit i scan_csv/scan_ndjson for at undgå sample-baseret type-inferens.




