Polars Spills to Disk Before It Runs Out of Memory
Polars counts every allocation in the process with a global allocator, and when that number crosses a budget, a background manager writes registered frames to IPC files on disk.
TL;DR: Polars does not measure the memory a particular operator uses. It counts every allocation the process makes with a thin wrapper around the global allocator, and compares that number against a budget (80% of RAM by default). Nodes in the streaming engine register their output frames as “spillable” with a spill context. When the global counter crosses the budget, a central memory manager picks candidates, encodes them as Arrow IPC streams, and writes them to files under a per-process spill directory. A second allocator-level threshold, off by default, aborts the process before the Linux OOM killer ever sees it.
The brief I was given for this article pointed at crates/polars-ooc/src/spill.rs,
write.rs, reader.rs, and an OocS3 spill manager. Those files do not exist
at this commit (b413f2b653). What exists is crates/polars-ooc/src/ with
global_alloc.rs, memory_manager.rs, spill_context/, spill_file.rs,
spill_frame.rs and spill_token.rs. There is no S3 backend: the only
spill target is the local filesystem, and the “reader” and “writer” are two
functions inside spill_frame.rs. So this is what is really there.
One counter for the whole process
The first thing to understand is that Polars’ out-of-core machinery is
built on a global memory estimate, not on per-operator accounting. There
is a GlobalAlloc implementation in crates/polars-ooc/src/global_alloc.rs
that wraps jemalloc (or mimalloc) and keeps a running byte total:
// crates/polars-ooc/src/global_alloc.rs:5
static GLOBAL_ALLOC_SIZE: AtomicU64 = AtomicU64::new(0);
/// Returns an estimate of the total amount of bytes allocated.
///
/// This can be up to OOC_DRIFT_THRESHOLD * num_threads bytes less than or
/// greater than the true memory usage.
pub fn estimate_memory_usage() -> u64 {
let bytes = GLOBAL_ALLOC_SIZE.load(Ordering::Relaxed);
if bytes > i64::MAX as u64 {
// Drift + moving allocations between threads allows for underflow,
// so this is best reported as zero.
0
} else {
bytes
}
}crates/polars-ooc/src/global_alloc.rs:5-20
The counter is not bumped on every alloc directly. Each thread accumulates
into a thread-local drift counter, and only flushes to the global atomic when
the drift exceeds POLARS_OOC_DRIFT_THRESHOLD (4 MiB by default):
// crates/polars-ooc/src/global_alloc.rs:28
#[inline(always)]
fn update_alloc_size(bytes: i64) {
LOCAL_ALLOC_DRIFT.with(|drift| {
let new = drift.get().wrapping_add(bytes);
drift.set(new);
if new.unsigned_abs() > polars_config::get_ooc_drift_threshold() {
flush_local_drift();
}
})
}
#[cold]
#[inline(never)]
fn flush_local_drift() {
LOCAL_ALLOC_DRIFT.with(|drift| {
let drift_bytes = drift.get();
let old = GLOBAL_ALLOC_SIZE.fetch_add(drift_bytes as u64, Ordering::AcqRel);
drift.set(0);
let total = old.wrapping_add(drift_bytes as u64);
if total <= i64::MAX as u64 && total > polars_config::get_oomkill_threshold_bytes() {
oomkill();
}
})
}crates/polars-ooc/src/global_alloc.rs:28-52
This is the same batching trick every high-performance allocator-backed
counter uses: one atomic fetch_add per 4 MiB per thread instead of one per
malloc. The cost is that the estimate can be off by up to
DRIFT_THRESHOLD * num_threads in either direction, which the doc comment
states plainly.
The Allocator itself is four lines of arithmetic wrapped around the
underlying allocator:
// crates/polars-ooc/src/global_alloc.rs:81
pub struct Allocator;
unsafe impl GlobalAlloc for Allocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
update_alloc_size(layout.size() as i64);
unsafe { UNDERLYING_ALLOC.alloc(layout) }
}
unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
update_alloc_size(layout.size() as i64);
unsafe { UNDERLYING_ALLOC.alloc_zeroed(layout) }
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
update_alloc_size(-(layout.size() as i64));
unsafe { UNDERLYING_ALLOC.dealloc(ptr, layout) }
}
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
update_alloc_size(new_size as i64 - layout.size() as i64);
unsafe { UNDERLYING_ALLOC.realloc(ptr, layout, new_size) }
}
}crates/polars-ooc/src/global_alloc.rs:81-103
In the Python build this is installed as the process-wide allocator:
// crates/polars-python/src/c_api/allocator.rs:7
#[global_allocator]
static ALLOC: polars_ooc::Allocator = polars_ooc::Allocator;crates/polars-python/src/c_api/allocator.rs:7-8
That last detail has real consequences. Because the allocator is process-global, the estimate includes everything the Python interpreter allocates — your list of strings, the dataframe you already materialized, the interpreter’s own bookkeeping. So the “Polars memory usage” number that drives spilling is really “this process’s memory usage”. Spilling can be triggered by allocations Polars never made. That is a deliberate trade: a single counter is cheap and always available, whereas per-operator accounting would need every allocation site to be instrumented.
The budget
The budget is derived from total system memory, with a cgroup-aware helper
(sys.cgroup_limits()) so containers get the right number:
// crates/polars-config/src/lib.rs:384
let budget_frac = f64::from_bits(self.ooc_memory_budget_fraction.load(Ordering::Relaxed));
let bytes = u64::min(
self.ooc_memory_budget_bytes.load(Ordering::Relaxed),
(total_memory() as f64 * budget_frac) as u64,
);
self.ooc_effective_memory_budget_bytes
.store(bytes, Ordering::Relaxed);
let frac = f64::from_bits(self.ooc_memory_prefetch_fraction.load(Ordering::Relaxed));
self.ooc_memory_prefetch_bytes
.store((bytes as f64 * frac) as u64, Ordering::Relaxed);crates/polars-config/src/lib.rs:384-393
The default fraction is 0.8 and the default absolute cap is u64::MAX
(POLARS_OOC_MEMORY_BUDGET_MB), so the effective budget is 80% of RAM
unless you set the absolute value. The prefetch threshold is 90% of the
budget, not of RAM — the gap between the two is the hysteresis band the
engine uses to decide between spilling more and reading some back.
There is a separate, much stricter threshold for the hard abort:
// crates/polars-config/src/lib.rs:395
let oomkill_frac = f64::from_bits(self.oomkill_threshold_fraction.load(Ordering::Relaxed));
let oomkill_frac_bytes = if oomkill_frac.is_finite() {
(total_memory() as f64 * oomkill_frac) as u64
} else {
u64::MAX
};
let oomkill_mb_bytes = self.oomkill_threshold_bytes.load(Ordering::Relaxed);
OOMKILL_THRESHOLD_BYTES_ATOMIC.store(
u64::min(oomkill_mb_bytes, oomkill_frac_bytes),
Ordering::Relaxed,
);crates/polars-config/src/lib.rs:395-404
Both POLARS_OOMKILL_THRESHOLD_FRACTION (default f64::INFINITY) and
POLARS_OOMKILL_THRESHOLD_MB (default u64::MAX) are effectively
unlimited out of the box, so the abort path is opt-in. When it does fire, it
is deliberately brutal — it cannot allocate, so it writes straight to fd 2
and calls libc::abort():
// crates/polars-ooc/src/global_alloc.rs:54
#[cold]
#[inline(never)]
fn oomkill() -> ! {
// Can't allocate here, so write directly to stderr.
const MSG: &[u8] = b"polars: memory usage exceeded POLARS_OOMKILL_THRESHOLD_MB or POLARS_OOMKILL_THRESHOLD_FRACTION, aborting\n";
unsafe {
libc::write(2, MSG.as_ptr().cast(), MSG.len() as _);
libc::abort()
}
}crates/polars-ooc/src/global_alloc.rs:54-63
abort() rather than a panic, because at that point a panic might need to
allocate to format its message, and the whole point is that we are out of
memory. Note also that the abort is checked only in flush_local_drift, so
it fires at most once per 4 MiB of allocation churn — it is a sampling
point, not an exact tripwire.
Making frames spillable
Now the other half. Nodes do not decide to spill; they make their output eligible for spilling and let a global manager decide.
The unit of spilling is a SpillToken<T>, a hand-rolled state machine over
a single AtomicU64. The bits are:
// crates/polars-ooc/src/spill_token.rs:17
// SpillTokenInner's state
const SPILLED_BIT: u64 = 1; // Set when value = None and spilled is Some.
const DROPPED_BIT: u64 = 2; // Set when the token owner has dropped. Forbids creation of new pins / lock.
const LOCK_BIT: u64 = 4; // When set no new pins may be made, and at most 1 thread may set this bit.
const HAS_WAITERS_BIT: u64 = 8; // Only updated while holding waiters lock, allows checking for waiters without locking.
const REINSERT_WHEN_SPILLABLE_BIT: u64 = 16; // When set the SpillToken should insert into its registered context once spillable.
const RO_PIN_COUNT_UNIT: u64 = 32; // Added to the state for each active pin.
const RO_PIN_MASK: u64 = u64::MAX << 5;crates/polars-ooc/src/spill_token.rs:17-24
A “pin” is a live reference to the in-memory value. Pin count lives in the
upper bits of the same word as the flags, so acquiring a pin is one
compare_exchange. Spilling requires upgrading a solo pin into the
exclusive lock — the transition that guarantees nobody is reading the
buffer while it is being encoded:
// crates/polars-ooc/src/spill_token.rs:715
// Try to upgrade solo pin into exclusive lock.
let is_exclusive = slf
.state
.try_update(Ordering::Acquire, Ordering::Acquire, |s| {
if s & (RO_PIN_MASK | LOCK_BIT) == RO_PIN_COUNT_UNIT {
Some(s - RO_PIN_COUNT_UNIT + LOCK_BIT)
} else {
None
}
})
.is_ok();crates/polars-ooc/src/spill_token.rs:715-725
If the upgrade fails, the spill is abandoned (add_failed_spill) and the
frame stays in memory. That failure rate is not incidental: it is measured
and fed back into the bandit that picks candidates.
Who decides
Nodes register their frames with a spill context. There are three
context flavours — MostRecentSpillContext, LeastRecentSpillContext and
RandomSpillContext — differing only in the order in which candidates are
popped from the queue. A context is a name plus a policy plus a statistics
object:
// crates/polars-ooc/src/spill_context/mod.rs:530
/// A context that spills the most-recently registered spillable when asked.
#[derive(Clone)]
#[repr(transparent)]
pub struct MostRecentSpillContext(StrongSpillContext);
impl MostRecentSpillContext {
pub fn new(name: PlSmallStr, task_metrics: Option<Arc<TaskMetricAggregator>>) -> Self {
Self(StrongSpillContext::new(
name,
SpillContextPolicy::MostRecent,
task_metrics,
))
}
}crates/polars-ooc/src/spill_context/mod.rs:530-543
The register call is where the global memory check happens. Note that
there are two variants: register_no_spill_check (synchronous, no global
check) and register (async, checks):
// crates/polars-ooc/src/spill_context/mod.rs:512
pub trait ParameterFreeSpillContext {
fn register_no_spill_check<T, S>(&self, token: &T)
where
T: AsRef<SpillToken<S>>,
S: Spillable,
Self: Sized;
fn register<T, S>(&self, token: &T) -> impl Future<Output = ()>
where
T: AsRef<SpillToken<S>>,
S: Spillable,
Self: Sized,
{
self.register_no_spill_check(token);
memory_manager().spill()
}
}crates/polars-ooc/src/spill_context/mod.rs:512-528
So registering a frame is the only trigger. The engine never asks “does this node exceed its share?”; it asks “are we over budget globally?” and if so, spills whatever is registered and eligible. The global memory manager is the only thing that reads the allocator counter:
// crates/polars-ooc/src/memory_manager.rs:61
fn should_spill(&self) -> bool {
let usage = crate::estimate_memory_usage();
let likely_dealt_with = self.est_spill_in_progress.load(Ordering::Relaxed);
let likely_incoming = self.est_prefetch_in_progress.load(Ordering::Relaxed);
(usage + likely_incoming).saturating_sub(likely_dealt_with)
> config().ooc_memory_budget_bytes()
}
pub(crate) fn should_prefetch(&self) -> bool {
if !self.spills_exist.load(Ordering::Acquire) {
return false;
}
let usage = crate::estimate_memory_usage();
let likely_incoming = self.est_prefetch_in_progress.load(Ordering::Relaxed);
(usage + likely_incoming) < config().ooc_memory_prefetch_bytes()
}crates/polars-ooc/src/memory_manager.rs:61-77
The subtraction of est_spill_in_progress matters: bytes that are already
being written to disk are still allocated (the encoded buffer lives in RAM
until the write completes), so counting them as “not yet freed” would make
the manager spill harder the more it spills. The estimate of in-flight
spill bytes is tracked by a SpillInProgressTracker whose Drop decrements
the counter — a RAII guard, so it cannot leak even if the spill task is
cancelled.
Picking a victim
Candidate selection is a multi-armed bandit over spill contexts, not a heuristic. Each context keeps discounted statistics of its own historical performance and is sampled via Thompson sampling:
// crates/polars-ooc/src/memory_manager.rs:289
// Thompson sampling.
let score_sample = ctx.0.stats().sample_spill_score(&mut rng);
assert!(!score_sample.is_nan());
live_contexts.push((ctx.clone(), score_sample));crates/polars-ooc/src/memory_manager.rs:289-292
The score itself is a ratio of relief to IO time — byte-seconds freed per second spent doing I/O:
// crates/polars-ooc/src/spill_context/stats.rs:481
if r >= 0.0 && t >= 0.0 {
return r / (BASE_IO_TIME + t);
}crates/polars-ooc/src/spill_context/stats.rs:481-483
where r = bytes * seconds_spilled and t = spill_time + unspill_time. A
context whose frames are cheap to write and expensive to read back gets a
low score and is avoided. All statistics decay with a one-second half-life,
because a plan that was a good spill candidate two minutes ago may not be
now. The algorithm is documented as an adaptation of “Discounted Thompson
Sampling for Non-Stationary Bandit Problems”.
Within the winning context, candidates are drained from the spill queue in policy order and filtered by size:
// crates/polars-ooc/src/memory_manager.rs:321
ctx.0.drain_live_while(|rt| {
let Some(cand) = rt.upgrade() else {
return true;
};
match cand.clone().spill_status() {
SpillStatus::InMemory(sz) if sz >= min_spill => {
candidates.push((cand, SpillInProgressTracker::new(sz), rt));
},
SpillStatus::InMemory(_) => {
cand.cancel_spill_attempt_and_reinsert(
rt.registration_id,
ctx.1,
InsertReason::TooSmall(rt.timestamp),
);
},
SpillStatus::Pinned => {
cand.cancel_spill_attempt_and_reinsert(
rt.registration_id,
ctx.1,
InsertReason::Unpin,
);
},
// A spilled token is re-inserted by whoever unspills it, a
// dropped one does not need re-inserting at all.
SpillStatus::Spilled | SpillStatus::Dropped => {},
}
num_considered += 1;
num_considered < SPILL_FRAME_BATCH_SIZE
});crates/polars-ooc/src/memory_manager.rs:321-350
Frames smaller than POLARS_OOC_SPILL_MIN_BYTES (64 KiB default) are not
worth the round trip and go to the back of the queue; pinned frames (being
read right now) are skipped and re-inserted when unpinned. The drain is
capped at 256 candidates per attempt.
Each surviving candidate is spilled on its own task, bounded by a semaphore
of POLARS_OOC_MAX_PARALLEL_SPILL_TASKS (64 by default):
// crates/polars-ooc/src/memory_manager.rs:137
for (spillable, spill_in_progress, rt) in spillables {
let permit = self.spill_semaphore.clone().acquire_owned().await.unwrap();
let successful_spill = successful_spill.clone();
let ctx = ctx.clone();
executor::spawn(TaskPriority::High, task_metrics.as_deref(), async move {
// Spill, or reinsert if a failure.
match spillable.clone().try_spill(ctx.clone()) {
Ok(spill_success) => {
if spill_success.await {
...crates/polars-ooc/src/memory_manager.rs:137-147
The on-disk format
Spilling a DataFrame means writing an Arrow IPC stream (not a file
format with random access — a stream, so it reads back sequentially):
// crates/polars-ooc/src/spill_frame.rs:26
async fn spill(&self, context_id: &str) -> Self::Spilled {
let mut df = self.clone();
let context_id = context_id.to_owned();
// Encode in the current task (on computational executor).
let mut buf = Vec::new();
let mut writer = IpcStreamWriter::new(&mut buf).with_compat_level(CompatLevel::newest());
let clvl = polars_config::config().ooc_spill_compression_level();
if clvl > 0 {
let zstd_lvl = ZstdLevel::try_new(clvl.try_into().unwrap()).unwrap();
writer = writer.with_compression(Some(IpcCompression::ZSTD(zstd_lvl)));
}
writer
.finish(&mut df)
.unwrap_or_else(|e| panic!("failed to encode spill file for '{context_id}': {e}",));crates/polars-ooc/src/spill_frame.rs:26-41
Two details worth noting. First, compression is off by default:
POLARS_OOC_SPILL_COMPRESSION_LEVEL defaults to 0, and the code only
attaches ZSTD when the level is positive. Compression costs CPU and the
spill file is usually read once, so the default trades disk for time. Second,
the encode happens on the task that owns the data (so the buffers are
already hot in cache) while the actual file write is pushed onto the tokio
runtime:
// crates/polars-ooc/src/spill_frame.rs:43
// Do file creation / writing on tokio.
let file = ASYNC
.spawn(async move {
let size = buf.len() as u64;
let spill_file = SpillFile::new(&context_id, "ipc", size);
if BYTES_SPILLED_TO_DISK.fetch_add(size).saturating_add(size)
> polars_config::config().ooc_disk_budget_bytes()
{
spill_file.creation_aborted();
polars_error::abort::polars_abort_ooc_out_of_disk();
}crates/polars-ooc/src/spill_frame.rs:43-53
The disk budget (POLARS_OOC_DISK_BUDGET_MB, 64 GB by default) is checked
against a process-wide counter of bytes spilled. Exceeding it aborts the
query rather than filling the disk.
The file layout is deliberately simple, with one directory per process so that a crashed run’s leftovers can be identified and removed:
<spill_dir>/
<pid>/ <- process directory (one per OS process)
spill-<ctx>-<uuid>.ipc <- individual spill file (unique per spill)crates/polars-ooc/src/spill_file.rs:9-15
The default spill directory is /var/tmp/polars-$USER/spill on Linux —
deliberately not std::env::temp_dir(), because on Linux that is often
tmpfs, and spilling to tmpfs defeats the entire purpose of spilling:
// crates/polars-config/src/spill_path.rs:16
pub fn default_ooc_spill_dir() -> PathBuf {
#[cfg(target_os = "linux")]
let (base, user_var) = (PathBuf::from("/var/tmp"), "USER");
...
let user = std::env::var(user_var).unwrap_or_else(|_| "unknown".to_string());
base.join(format!("polars-{user}")).join("spill")
}crates/polars-config/src/spill_path.rs:16-31
The spill file is deleted when the SpillFile is dropped, but deletion is
handed off to a background thread so the hot path never blocks on
unlink:
// crates/polars-ooc/src/spill_file.rs:55
impl Drop for SpillFile {
fn drop(&mut self) {
SPILL_CLEANER
.send_rq
.send(CleanRequest::File(
core::mem::take(&mut self.path),
self.size,
))
.unwrap();
}
}crates/polars-ooc/src/spill_file.rs:55-65
The same cleaner thread, started lazily, also sweeps directories left by dead processes at startup — it reads the PID from each subdirectory name and checks whether that PID is still alive:
// crates/polars-ooc/src/spill_file.rs:127
fn cleanup_stale_dirs() {
let spill_dir = polars_config::config().ooc_spill_dir();
let Ok(entries) = std::fs::read_dir(&spill_dir) else {
return;
};
let our_pid = std::process::id();
let dead_pid_dirs = entries.flatten().filter_map(|e| {
let pid = e.file_name().to_str()?.parse::<u32>().ok()?;
(pid != our_pid && !polars_utils::sys::is_process_alive(pid)).then(|| e.path())
});
for path in dead_pid_dirs {
let _ = std::fs::remove_dir_all(&path);
}
}crates/polars-ooc/src/spill_file.rs:127-141
Reading back
Unspilling is the mirror image: open the file and decode it, on a blocking thread since it is filesystem I/O:
// crates/polars-ooc/src/spill_frame.rs:71
async fn unspill(location: &Self::Spilled) -> Self {
let path = location.path().to_owned();
ASYNC
.spawn_blocking(move || {
let file = std::fs::File::open(&path).unwrap_or_else(|e| {
panic!("failed to open spill file {:?}: {e}", path.display())
});
IpcStreamReader::new(file).finish().unwrap_or_else(|e| {
panic!("failed to read spill file {:?}: {e}", path.display())
})
})
.await
.unwrap()
}crates/polars-ooc/src/spill_frame.rs:71-84
But nobody waits for a spill to be read back if they can avoid it. When memory usage drops below the prefetch threshold (90% of budget), the manager proactively pulls spilled frames back into memory on low-priority tasks, so the eventual read is a cache hit:
// crates/polars-ooc/src/spill_context/mod.rs:362
for _ in 0..self.stats.suggested_prefetch_amount() {
let Some(permit) = mm.try_get_prefetch_permit() else {
return PrefetchScheduleResult::NoPermitsLeft;
};
let pop = match self.policy() {
SpillContextPolicy::MostRecent => shared.unspill_queue.pop_front(),
SpillContextPolicy::LeastRecent => shared.unspill_queue.pop_back(),
SpillContextPolicy::Random => shared.unspill_queue.pop_random(&mut rng),
};crates/polars-ooc/src/spill_context/mod.rs:362-371
Prefetching is scheduled by a separate bandit (sample_prefetch_score), and
the amount of prefetching scales with how long it has been since the last
spill: suggested_prefetch_amount returns 1 + prefetch_without_spill_streak.
If nothing is being spilled, the engine reads more aggressively ahead of
demand.
Wiring it into the streaming engine
Everything above is machinery. The nodes are what make it useful.
The unit of work in the streaming engine is a Morsel, and every morsel
carries a SpillFrame:
// crates/polars-stream/src/morsel.rs:82
#[derive(Debug)]
pub struct Morsel {
/// The data contained in this morsel.
sf: SpillFrame,
/// The sequence number of this morsel. May only stay equal or increase
/// within a pipeline.
seq: MorselSeq,
...
}crates/polars-stream/src/morsel.rs:82-96
A node that buffers its input registers each incoming morsel with its context, so the buffered data becomes spillable while it waits:
// crates/polars-stream/src/nodes/in_memory_sink.rs:66
for mut recv in receivers {
let slf = &*self;
join_handles.push(scope.spawn_task(TaskPriority::High, async move {
let mut morsels = Vec::new();
while let Ok(mut morsel) = recv.recv().await {
morsel.take_consume_token();
slf.spill_ctx.register(morsel.sf()).await;
morsels.push(morsel);
}
slf.morsels_per_pipe.lock().push(morsels);
Ok(())
}));
}crates/polars-stream/src/nodes/in_memory_sink.rs:66-79
The sort node does the same for its buffered input, and additionally uses the memory budget to decide when to stop sorting in memory and start partitioning:
// crates/polars-stream/src/nodes/sort/tuning.rs:61
/// The buffered size above which the input is partitioned instead of
/// sorted in memory.
pub fn partition_threshold(&self) -> u64 {
self.partition_threshold_bytes.min(budget() / 2)
}
/// The number of buckets that may be sorted or waiting to be emitted at
/// once while flushing: two per pipeline, as long as they take at most a
/// quarter of the memory budget.
pub fn flush_ahead(&self, bucket_bytes: u64, num_pipelines: usize) -> usize {
let fit = budget() / 4 / bucket_bytes.max(1);
usize::try_from(fit)
.unwrap_or(usize::MAX)
.clamp(1, 2 * num_pipelines)
}crates/polars-stream/src/nodes/sort/tuning.rs:61-75
That is the hybrid strategy in one place: the sort node does not spill blindly, it partitions its input when the buffered bytes exceed half the budget, and the partition buckets are individually registered as spillable. The hash shuffle does something similar, keeping per-partition frames registered so the shuffle can spill under pressure:
// crates/polars-stream/src/hash_shuffle/store.rs:64
let frame = SpillFrameWithKeys::new_unregistered(builder.freeze_reset());
self.spill_ctx.register_no_spill_check(&frame);
self.estimated_size += frame.estimated_size() as u64;
self.frames_per_input[input].push_back(frame);crates/polars-stream/src/hash_shuffle/store.rs:64-67
Note register_no_spill_check here: the shuffle registers without asking
the global manager to spill, because it is on the hot path and does not
want to block on a global decision. It relies on other nodes’ register
calls to drive the check, and on the explicit memory_manager().spill()
calls sprinkled through the shuffle’s own build and load loops.
The join nodes follow the same pattern with a MostRecentSpillContext
(the build side of an equi-join keeps its probe keys in spillable frames),
and the group-by keeps cold rows in SpillFrameWithKeys so that the rows
and their hashed keys always spill and unspill together — the keys are
needed to re-partition the rows, so splitting them across a frame and a
side file would be a bug waiting to happen.
Aborting cleanly
Running out of disk is a normal, expected outcome of a query that decided
to spill, so it is turned into an ordinary Rust error rather than a crash.
polars-error/src/abort.rs implements a panic-based abort protocol with a
magic string in the panic payload:
// crates/polars-error/src/abort.rs:12
pub enum QueryAborted {
KeyboardInterrupt,
OocOutOfDisk,
}
// We use a unique string so we can detect it in backtraces.
static POLARS_ABORT_PREFIX: &str = "__POLARS_ABORT_";
static POLARS_ABORT_KEYBOARD_INTERRUPT_STR: &str = "__POLARS_ABORT_KEYBOARD_INTERRUPT";
static POLARS_ABORT_OOC_OUT_OF_DISK_STR: &str = "__POLARS_ABORT_OOC_OUT_OF_DISK";crates/polars-error/src/abort.rs:12-20
The out-of-disk abort can be raised from anywhere — including from inside
a tokio task with no PolarsResult return path — so it is a panic with a
recognisable payload, caught by catch_polars_abort at the Python
boundary:
// crates/polars-python/src/utils.rs:147
let ret = self.detach(|| catch_polars_abort(AssertUnwindSafe(f)));
match ret {
Ok(Ok(ret)) => Ok(ret),
Ok(Err(err)) => Err(PyErr::from(err.into())),
Err(QueryAborted::KeyboardInterrupt) => Err(PyKeyboardInterrupt::new_err("")),
Err(QueryAborted::OocOutOfDisk) => Err(ComputeError::new_err(
"query aborted: spilled data exceeded the out-of-core disk budget, increase POLARS_OOC_DISK_BUDGET_MB",
)),
}crates/polars-python/src/utils.rs:147-155
The same mechanism doubles as the Ctrl-C path: Python hooks SIGINT, the
Rust side sets a bit in an atomic, and every try_raise_polars_abort() in
a hot loop turns that bit into the same kind of panic. The panic hook
suppresses the usual panic message when a catcher is installed, so the user
sees a clean KeyboardInterrupt rather than a Rust backtrace.
The bottom line
The design is coherent once you see the constraint it is built around: the engine must never let the process run out of memory, and it must never pay for spilling more than necessary.
- Accounting is global and approximate. One counting allocator for the
whole process, sampled every 4 MiB per thread. The estimate can be off by
4 MiB * threadsin either direction, and it includes Python’s own allocations. In exchange it works everywhere with zero instrumentation of individual operators. - Nodes do not decide to spill. They register frames as candidates; a
single global manager compares the counter against the budget
(
POLARS_OOC_MEMORY_BUDGET_FRACTION, default 80% of RAM) and picks victims using a discounted Thompson-sampling bandit over spill contexts, scoring each context by byte-seconds of relief per second of I/O. - The on-disk format is a plain Arrow IPC stream, one file per spill
under
/var/tmp/polars-$USER/spill/<pid>/, ZSTD only if you ask for it, deleted asynchronously and swept by PID liveness check after a crash. - Reading back is anticipatory. Below 90% of the budget the engine prefetches spilled frames onto low-priority tasks, so the read that eventually happens is a cache hit instead of a disk wait.
- There is a hard abort before the OOM killer, but it is off by default
(
POLARS_OOMKILL_THRESHOLD_MB/_FRACTIONare unlimited out of the box). When enabled it callslibc::abort()— no unwinding, no cleanup. Running out of disk, by contrast, is a clean, catchable error.
To use it, set the budget and let the engine work:
# Spill aggressively: 25% of RAM, tiny frames still worth spilling.
export POLARS_OOC_MEMORY_BUDGET_FRACTION=0.25
export POLARS_OOC_SPILL_MIN_BYTES=1048576
export POLARS_OOC_SPILL_DIR=/mnt/big-disk/polars-spill
# Compress spill files (costs CPU, saves disk).
export POLARS_OOC_SPILL_COMPRESSION_LEVEL=3
# Die with a clear message instead of being OOM-killed.
export POLARS_OOMKILL_THRESHOLD_FRACTION=0.9The trade-offs are real. Spilling to disk costs a serialization round trip and a read-back later, and the bandit is explicitly trying to avoid contexts where that round trip is expensive. Frames under 64 KiB are never spilled because the overhead dominates. And because the counter is process-wide, a Python program holding several gigabytes of its own data can trigger spilling of Polars frames that are nowhere near their size limit. If you are memory-constrained, lowering the budget below 100% is usually the right move — you want Polars to start spilling while there is still headroom, not when the allocator’s threshold is already crossed.