Author: kongastral

  • Streaming vs Batch Processing Architectures: A Data Engineer’s Guide

    Every data platform eventually confronts a single design question that shapes its cost, its latency, and its operational burden: should a given computation run over a finite dataset on a schedule, or continuously over an endless flow of events? Batch processing computes over bounded data in periodic runs, while stream processing computes incrementally over an unbounded sequence of events as they arrive. The two paradigms are often presented as rivals, but they are better understood as points on a single continuum that trades data freshness against cost, throughput, and correctness machinery. This guide provides a mechanistic, vendor-neutral model of that continuum for engineers who already operate pipelines and now need a rigorous basis for deciding where each new workload belongs.

    The discussion covers the batch-versus-streaming trade-off surface, the Lambda and Kappa architecture debate, the execution split between micro-batch and event-at-a-time engines, the correctness semantics that make streaming trustworthy (event time, watermarks, windowing, and exactly-once processing), an honest account of where batch remains the correct choice, and the convergence of the two paradigms observed across 2026 toward unified engines and the streaming lakehouse.

    Summary

    What this post covers: A vendor-neutral comparison of batch and stream processing architectures for data engineering, including the Lambda-versus-Kappa debate, the micro-batch-versus-event-at-a-time execution split, and the correctness semantics (event time, watermarks, windowing, exactly-once) that streaming systems depend on.

    Key insights:

    • Batch is a special case of streaming: a batch job is a stream computation over a bounded input placed in a single global window, a reframing formalized by the Google Dataflow model.
    • Lambda architecture pays for low latency with two parallel codebases that must be reconciled, while Kappa removes the batch layer by treating reprocessing as a replay of a durable, retained log such as Apache Kafka.
    • Micro-batch engines such as Spark Structured Streaming trade a latency floor near 100 milliseconds for simple recovery and high throughput, whereas event-at-a-time engines such as Apache Flink process each record on arrival for lower latency and a richer time and state model.
    • Correct windowed aggregation requires event time rather than processing time, and watermarks are the mechanism by which a system decides that an event-time window is complete enough to emit.
    • Exactly-once processing in Flink is achieved through asynchronous barrier snapshotting, and end-to-end exactly-once delivery to external sinks additionally requires a two-phase commit; the choice between at-least-once and exactly-once is a cost decision, not a purely technical one.
    • Batch remains the correct choice for large historical backfills, full-refresh reproducibility, cost-sensitive periodic reporting, and machine-learning training sets, and the 2026 convergence toward unified engines and streaming lakehouse table formats is collapsing the two-store split rather than eliminating either paradigm.

    Main topics: Two Ways to Compute Over Data; Lambda and Kappa: The Architecture Debate; Execution Models: Micro-Batch and Event-at-a-Time; Getting Streaming Correct: Time, Watermarks, Windows, and Semantics; When Batch Remains the Right Choice, and the Convergence.

    Two Ways to Compute Over Data

    The distinction between batch and streaming begins with the shape of the input. A bounded dataset is finite and complete: yesterday’s transactions, a snapshot of a table, a directory of log files. An unbounded dataset is an endless sequence of events that has no defined end, such as clickstream events, sensor readings, or a change-data-capture feed from a production database. Batch processing operates over bounded data in scheduled runs, producing a result after it has observed the entire input. Stream processing operates over unbounded data continuously, updating results incrementally as each event arrives.

    This difference in input shape propagates into every operational property of a pipeline. The most useful way to reason about the choice is as a trade-off surface with three axes: latency, throughput, and cost. Latency is the delay between an event occurring and its effect appearing in a result. Throughput is the volume of records the system processes per unit of time. Cost is the compute and operational expense of running the system. These three cannot be optimized independently. Lowering latency toward the millisecond range generally requires always-on compute and additional correctness machinery, which raises both cost and operational complexity. Tolerating higher latency allows work to be amortized into scheduled bursts, which lowers steady-state cost and simplifies recovery.

    The Latency / Throughput / Cost Trade-off Low latency High throughput Low cost Streaming always-on, fresh Batch bursty, amortized Freshness is purchased with steady compute and added complexity; latency tolerance buys amortized cost.

    A central insight, formalized by the Google Dataflow model (Akidau et al., The Dataflow Model, PVLDB 2015), dissolves the apparent opposition between the two paradigms. In that framework, a batch job is simply a stream computation over a bounded input assigned to a single global window that closes when the input is exhausted. Streaming generalizes batch rather than replacing it: the same logical operations (filtering, joining, aggregating) apply in both cases, and the differences reduce to when results are emitted and how completeness is judged. This reframing is more than a rhetorical convenience, because it underpins the unified engines discussed later, in which one API expresses both bounded and unbounded computation.

    The following table summarizes how the paradigm choice affects the operational properties an engineer must plan around.

    Dimension Batch Streaming
    Input Bounded, complete dataset Unbounded event sequence
    Typical latency Minutes to hours Milliseconds to seconds
    Throughput profile High, bursty High, steady
    Cost profile Amortized, scheduled Always-on, continuous
    Reproducibility Simple full re-run Needs replay plus retained state
    Operational complexity Lower Higher
    Representative tools Airflow, Spark batch, dbt Flink, Spark Structured Streaming, Kafka

     

    Key Takeaway: The batch-versus-streaming decision is not a choice between two technologies but a position on a latency-throughput-cost surface. Because a batch job is a streaming job over a single global window, the same engine and even the same code can increasingly express both, which shifts the question from “which paradigm” to “how fresh must this result be, and what is that freshness worth.”

    Lambda and Kappa: The Architecture Debate

    Once an organization needs both historical accuracy and low-latency views, it confronts an architectural question that predates modern unified engines. The two canonical answers are the Lambda architecture and the Kappa architecture, and understanding their motivations clarifies design decisions that remain relevant even where neither is adopted by name.

    The Lambda Architecture

    The Lambda architecture was described by Nathan Marz, the creator of Apache Storm, around 2011 and later formalized with James Warren in the book Big Data (Manning, 2015). It composes three layers. The batch layer holds an immutable master dataset and precomputes comprehensive batch views over all historical data, prioritizing accuracy and completeness. The speed layer processes only recent data with low latency, producing approximate or incremental real-time views that compensate for the batch layer’s delay. The serving layer indexes and merges the outputs of both layers so that a query sees a combined result: authoritative history from the batch layer plus the most recent events from the speed layer.

    Lambda Architecture Data source Batch layer master dataset + precomputed views Speed layer low-latency real-time views Serving layer merge + index Query Same logic, two codebases

    The Lambda architecture achieves both accuracy and freshness, but at a well-known cost: the same business logic must be implemented twice, once in a batch engine and once in a stream engine, and the two implementations must be kept semantically equivalent. Any divergence between them produces inconsistent results at the serving layer, and every change to the computation must be applied and validated in both places. This dual-codebase reconciliation burden is the defining pain point of the pattern.

    The Kappa Architecture

    Jay Kreps, a co-creator of Apache Kafka, proposed an alternative in the 2014 essay Questioning the Lambda Architecture (O’Reilly Radar). The argument is direct: if a stream processor is sufficiently expressive and reliable, the separate batch layer is redundant. The Kappa architecture keeps a single streaming layer and backs it with a durable, replayable log, typically Kafka, that retains the raw event history. There are no longer two code paths. When the computation logic changes, or a bug is fixed, “reprocessing” means starting a new instance of the streaming job from the beginning of the retained log and letting it recompute the output, then switching consumers to the new result. The nightly recompute of the batch layer is replaced by a replay of the same code that serves live traffic.

    Kappa Architecture Data source Replayable log (Apache Kafka) retained, ordered Stream processor single code path (job version N) Serving store / query Reprocess = replay log through job N+1

    The Kappa architecture depends on two properties of its log. The log must be durable and retained long enough to replay whatever history a recomputation requires, and it must preserve ordering per partition so that replay is deterministic. Kafka provides both, which is why it sits at the center of most Kappa deployments. Implementing a reliable consumer over that log is itself a substantial task; the mechanics of offset management, consumer groups, and rebalancing are treated in this guide to implementing a Kafka consumer in Python. The events feeding such a log frequently originate from operational databases through change-data-capture, in which row-level changes are streamed as events; the change-data-capture pattern with Debezium and Kafka is a canonical source that makes Kappa practical for database-derived data.

    Aspect Lambda Kappa
    Code paths Two (batch + speed) One (streaming)
    Reprocessing Batch recompute Replay the retained log
    Storage Batch store + speed store Single replayable log
    Reconciliation Serving-layer merge None; single path
    Operational burden Higher Lower
    Best fit Reconciliation- and audit-heavy domains Freshness-first, replayable sources

     

    The industry has drifted toward Kappa because a single codebase is cheaper to maintain and because modern stream engines are expressive enough to carry the full computation. Lambda has not disappeared, however. It survives in domains where an independent, authoritative batch recomputation over an immutable master dataset serves as a correctness audit and a reconciliation baseline, particularly where regulatory or financial reporting demands a reproducible ground truth computed separately from the live path. In practice, many teams run a hybrid: a Kappa-style streaming path for freshness and a periodic batch job that reconciles and corrects, which is closer to Lambda in spirit than either label admits.

    Execution Models: Micro-Batch and Event-at-a-Time

    Below the architectural layer sits a second decision that determines a pipeline’s latency floor and recovery behavior: how the engine physically executes the stream. Two designs dominate, and they differ in the unit of work they process at a time.

    Micro-batch execution

    Spark Structured Streaming uses micro-batch execution by default. The incoming stream is divided into a sequence of small, deterministic batches, and each batch is executed as an ordinary Spark job over the records that accumulated during a short interval. This design inherits the fault-tolerance and exactly-once guarantees of Spark’s batch engine almost for free, because recovery means re-running a deterministic batch. According to the Spark Structured Streaming Programming Guide (version 4.1.x, 2026), the default micro-batch engine achieves end-to-end latencies as low as roughly 100 milliseconds with exactly-once guarantees. The trade-off is that the batch boundary imposes a latency floor: a result cannot appear until its micro-batch has been formed and executed. Micro-batch execution is well suited to high-throughput workloads where a latency of a fraction of a second is acceptable.

    Spark has offered lower-latency options over time. A Continuous Processing mode, introduced experimentally in Spark 2.3, processes records with latency near one millisecond but provides only at-least-once guarantees. More recently, Spark 4.1 added a Real-Time Mode for Structured Streaming that targets sub-second and single-digit-millisecond latency for stateless tasks (Spark 4.1.0 release notes and the associated Databricks engineering blog, 2026).

    Event-at-a-time execution

    Apache Flink processes each record as it arrives, one event at a time, through a long-lived graph of operators. There is no batch boundary to wait for, so latency is bounded by the processing of an individual record rather than by an interval. This event-at-a-time model gives Flink native event-time semantics and a rich model of state and timers, which makes it a natural fit for complex, low-latency stream computations. Pattern-matching over event streams is one such application; the complex event processing pipeline with Flink CEP illustrates how the event-at-a-time model supports detecting temporal patterns across a continuous stream.

    Micro-batch vs Event-at-a-Time Micro-batch (Spark Structured Streaming) batch 1 batch 2 batch 3 batch 4 Result emitted per interval; latency floor near 100 ms (default engine). Event-at-a-time (Apache Flink) Each record flows through the operator graph on arrival; latency bounded per record. Longer-lived operators carry state and timers directly.

    Apache Beam occupies a distinct position in this landscape. Rather than being an execution engine, Beam is a programming model that implements the Dataflow abstraction and compiles the same pipeline to different execution engines, called runners, including Flink and Spark. The Beam release line reached version 2.74.0 (2026-06-02), which added a Spark 4 runner for the Java software development kit (Beam downloads page, 2026). Beam lets an engineer write a computation once against the Dataflow model and choose the execution backend separately, which is the clearest embodiment of the “batch is a special case of streaming” principle.

    Property Micro-batch (Spark SS) Event-at-a-time (Flink) Continuous / RTM (Spark)
    Unit of work Small deterministic batch Single record Single record
    Minimum latency ~100 ms Milliseconds ~1 ms to sub-second
    Default semantics Exactly-once Exactly-once At-least-once (legacy CP)
    Recovery model Re-run deterministic batch Restore from snapshot Restart from offset
    Throughput High High Workload dependent

     

    The engine version landscape as of mid-2026 is worth noting for teams selecting a platform. Apache Flink reached 2.3.0 (2026-06-25), with a long-term-support line at 1.20.x, following the 2.0.0 major release in March 2025. Apache Spark’s current stable releases are 4.1.2 (2026-05-21) and 4.0.3 on the 4.0 line. Apache Kafka reached 4.3.1 (2026-06-25); notably, Kafka 4.0.0 removed ZooKeeper entirely, making the KRaft consensus protocol the only operating mode (Flink, Spark, and Kafka download and release pages, 2026).

    Caution: Published latency figures such as “as low as 100 ms” describe favorable conditions and specific engine modes, not a guarantee for an arbitrary workload. Actual latency depends on state size, shuffle behavior, backpressure, and sink characteristics. Treat these numbers as the shape of the trade-off rather than a service-level objective, and measure the specific pipeline before committing to it.

    Getting Streaming Correct: Time, Watermarks, Windows, and Semantics

    The difficulty of streaming is not moving events quickly; it is producing correct answers over data that arrives late, out of order, and without a natural end. This section covers the machinery that makes streaming results trustworthy, which is the substance that separates a robust pipeline from one that silently produces wrong aggregates.

    Event time versus processing time

    Event time is the moment an event actually occurred, recorded as a timestamp embedded in the record itself. Processing time is the moment the pipeline observes the event, which depends on network delay, buffering, and system load. In any real distributed system these two clocks diverge, and the gap between them, called event-time skew, is variable. A mobile device that loses connectivity may deliver an event minutes after it occurred. Because business questions are almost always framed in terms of when events happened rather than when a server saw them, correct windowed aggregation must be computed in event time. Aggregating in processing time is simpler but produces results that shift depending on system latency, which makes them non-reproducible.

    Event Time, Processing Time, and the Watermark Event time → Processing time → ideal (skew = 0) watermark ! late data (past watermark) points below the diagonal = out-of-order arrival

    Watermarks

    A watermark is a monotonic assertion about completeness in the event-time domain. A watermark of value T declares that the system expects no further events with a timestamp at or before T. This is precisely the mechanism a streaming system uses to decide when an event-time window is complete enough to close and emit a result. A watermark that advances aggressively produces low-latency results but risks excluding genuinely late events; a conservative watermark waits longer, includes more late data, and increases latency. The watermark is therefore the tuning knob that trades completeness against latency, and it is defined per pipeline based on how late data is expected to arrive.

    Events that arrive after the watermark has passed their window are late data. Systems handle late data through configurable policies: an allowed-lateness period keeps a window’s state alive for a grace interval, triggers can fire refined results as more data arrives, and retractions can withdraw and correct a previously emitted result. These options are drawn directly from the Dataflow model’s framing of when results are materialized and how refinements relate to earlier outputs.

    Windowing

    Windowing divides an unbounded stream into finite chunks over which aggregation is defined. Three window types are canonical. A tumbling window is fixed-size, contiguous, and non-overlapping, so each event belongs to exactly one window; a five-minute tumbling window partitions time into adjacent five-minute blocks. A sliding window has a fixed size and a separate slide interval and therefore overlaps, so a single event can belong to several windows; a ten-minute window that advances every minute is a sliding window. A session window is data-driven and gap-based: it groups events separated by less than a configured inactivity gap and closes after the gap elapses, producing variable-length windows that are not aligned across keys, which is well suited to modeling bursts of user activity.

    Window Types Tumbling non-overlapping; each event in exactly one window Sliding overlapping; one event in several windows Session gap gap variable size; a window closes after an inactivity gap event time →

    Window Overlap Event membership Typical use
    Tumbling None Exactly one window Periodic totals (per-minute counts)
    Sliding Yes Several windows Moving averages, rolling metrics
    Session None; gap-defined One session per activity burst User sessions, activity grouping

     

    Delivery semantics and exactly-once

    Delivery semantics describe how many times an event’s effect can be reflected in the output when failures and retries occur. At-most-once processing may drop records and never duplicates them; it is fire-and-forget and cheapest. At-least-once processing never loses a record but may apply it more than once after a retry or recovery, producing duplicates. Exactly-once processing guarantees that each record affects the computed state exactly once despite failures, which is what most correctness-sensitive aggregations require.

    Semantics Duplicate risk Loss risk Mechanism
    At-most-once None Possible Fire-and-forget
    At-least-once Possible None Retry on failure
    Exactly-once None None Checkpoint + two-phase commit

     

    Flink achieves exactly-once state consistency through asynchronous barrier snapshotting, a variant of the Chandy-Lamport distributed snapshot algorithm (Flink Stateful Stream Processing documentation, 2026). The coordinating JobManager periodically injects special records called checkpoint barriers into the streams at the sources. As a barrier flows downstream, it separates the records that belong to the current snapshot from those that belong to the next one. When an operator has multiple input channels, it performs barrier alignment: it waits until the barrier has arrived on every input channel before taking its snapshot, buffering records that arrive after the barrier on faster channels. Once all operators have snapshotted their state, the checkpoint is complete and can be used to restore the entire job after a failure.

    Exactly-Once via Barrier Snapshotting and Two-Phase Commit Source A Source B Operator(aligns barriers) Committer(2-phase) Sink barrier alignment: wait for barrier on both inputs prepare → commit on checkpoint State snapshot + transactional sink commit together give end-to-end exactly-once. Alignment adds latency under backpressure, which motivated unaligned checkpoints.

    Internal exactly-once state is not sufficient by itself. To make results exactly-once all the way to an external system, the sink must participate in the checkpoint through a two-phase commit: it prepares (writes data in an uncommitted transaction) as part of a checkpoint and commits only after the checkpoint has completed successfully, so that a failure before completion leaves nothing visible to downstream readers. Flink implements this through a transactional committer for sinks that support transactions or idempotent writes. Kafka’s transactional producer and its durable, ordered-per-partition log are what make such end-to-end guarantees possible on the sink side.

    Barrier alignment has a cost. Under backpressure, an operator can stall while waiting for a barrier on a slow channel, which delays the checkpoint and raises latency. This motivated unaligned checkpoints, in which barriers overtake buffered records and the in-flight data is included in the snapshot instead, trading a larger checkpoint for shorter alignment delay. The default in Flink remains exactly-once with aligned checkpoints, and the choice to relax it is a deliberate latency optimization.

    Tip: The choice between at-least-once and exactly-once should be made per sink, not globally. If a downstream consumer deduplicates by an idempotency key, or the aggregation is itself idempotent, at-least-once may deliver the same correctness at lower cost and latency than full exactly-once with two-phase commit. Reserve exactly-once for cases where duplicates genuinely corrupt the result.

    When Batch Remains the Right Choice, and the Convergence

    The correctness machinery above should make one point clear: streaming buys freshness at the price of real complexity. For many workloads that price is not justified, and batch remains the correct and often superior choice. Recognizing these cases is as important as knowing how to build a stream.

    Batch is the better fit for large historical backfills, where terabytes of accumulated data must be reprocessed and there is no latency requirement at all. It is preferable for full-refresh reproducibility, where the ability to re-run a deterministic job and obtain an identical, auditable result is worth more than freshness. It suits complex multi-source joins that tolerate latency, cost-sensitive periodic reporting where always-on compute would be wasteful, and the construction of machine-learning training sets, which are inherently snapshots over a bounded, versioned dataset. It also remains the natural home for correctness-audit reprocessing, the independent recomputation that underpins the surviving uses of Lambda.

    The batch layer has its own mature tooling. Scheduled batch pipelines are commonly orchestrated with a workflow scheduler; the Apache Airflow orchestration guide describes how directed acyclic graphs express batch dependencies and retries. Transformation logic over warehouse tables is frequently expressed with a batch-first tool as covered in the dbt transformation pipeline guide. Batch outputs typically land in columnar files whose layout is examined in the Parquet and Arrow internals guide, and interactive analysis over such files increasingly runs in in-process engines compared in the DuckDB and Polars comparison.

    The 2026 convergence

    The sharp dichotomy between batch and streaming is eroding along three lines. The first is unified engines and APIs: Beam’s single model runs on multiple runners, and both Spark and Flink execute bounded and unbounded jobs through largely shared machinery, so the same logic serves both modes. The second is incremental processing: rather than fully reloading a dataset, a pipeline recomputes only the portion that changed, which imports a streaming efficiency into what looks like a batch job. The third is the streaming lakehouse, in which a single table serves both batch queries and streaming reads and writes.

    The Streaming Lakehouse: One Store, Two Access Modes Streaming writer Lakehouse table Paimon / Iceberg / Delta / Hudi ACID + changelog Streaming consumer Batch query One table replaces Lambda’s separate batch store and speed store.

    Open table formats provide the foundation for the lakehouse by adding transactional guarantees and metadata to files in object storage; the trade-offs among them are examined in the Iceberg, Delta Lake, and Hudi comparison. A streaming-native format, Apache Paimon, an Apache Top-Level Project since 2024, extends this idea further by combining a log-structured merge-tree with a changelog stream, so that the same table can be written and read as a continuous stream while also supporting batch queries and incremental reads (Apache Paimon project, 2026). The practical effect is that Lambda’s two separate stores collapse into one, which removes the reconciliation burden that motivated the Kappa argument in the first place.

    Key Takeaway: Convergence does not mean streaming has won and batch is obsolete. It means the two paradigms increasingly share an engine, an API, and a storage layer, so the decision moves from choosing a technology stack to choosing a freshness requirement per dataset and letting the same platform serve both.

    A Decision Checklist

    The following questions guide a workload toward batch or streaming without prescribing a specific engine. They are ordered so that the strongest determinants come first.

    Question Leans batch Leans streaming
    How fresh must the result be? Minutes to hours is acceptable Seconds or less is required
    Is the input bounded or unbounded? A finite, complete dataset A continuous event feed
    Is full-refresh reproducibility essential? Yes, an auditable re-run is needed Replay plus state is acceptable
    What is the tolerance for operational complexity? Low; a small team Higher; state and watermarks are manageable
    Does cost favor bursts or steady load? Scheduled bursts amortize better Freshness justifies always-on compute

     

    When several answers point in the same direction, the decision is clear. When they conflict, the convergence tooling offers a middle path: build on a unified engine and a lakehouse table so that a workload can begin as batch and gain a streaming read later without a rewrite, deferring the commitment until the freshness requirement is genuinely established.

    Frequently Asked Questions

    Is streaming always more expensive than batch?

    Not in a fixed ratio. Streaming runs always-on compute, which produces a steady cost that pays for freshness, while batch runs in scheduled bursts whose cost is amortized. Whether streaming costs more for a given workload depends on the state size, the shuffle behavior, and how continuously the data actually arrives. It is more accurate to reason about the shape of the trade-off, always-on versus bursty, than to apply a single multiplier.

    Why not just use processing time instead of event time?

    Processing-time aggregation is simpler because it ignores when events actually occurred, but its results shift with system latency and are therefore non-reproducible. If a network delay causes events to arrive late, a processing-time window attributes them to the wrong interval. Business questions are almost always framed in event time, so correct windowed results require it, which in turn requires watermarks to judge completeness.

    What is the practical difference between at-least-once and exactly-once?

    At-least-once never loses a record but may apply it more than once after a failure, producing duplicates. Exactly-once guarantees each record affects the result exactly once, at the cost of checkpointing and, for external sinks, a two-phase commit. If the downstream system deduplicates or the operation is idempotent, at-least-once can deliver equivalent correctness more cheaply; exactly-once is warranted when duplicates genuinely corrupt the output.

    Does the convergence toward unified engines make Lambda and Kappa obsolete?

    It reduces the cost that motivated the debate rather than settling it. Streaming lakehouse table formats collapse Lambda’s two stores into one, which removes much of the reconciliation burden. An independent batch recomputation still has value as a correctness audit in regulated or reconciliation-heavy domains, so Lambda-style patterns persist even as the tooling makes a single code path easier to maintain.

    Is micro-batch a form of batch or streaming?

    It is streaming implemented by repeatedly running very small batch jobs. Spark Structured Streaming chops an unbounded stream into short, deterministic micro-batches, which inherits batch-style recovery and exactly-once guarantees while continuously producing results. It sits between the two paradigms and illustrates why the batch-streaming boundary is better viewed as a continuum than a hard line.

    References

    Conclusion

    Batch and stream processing are not opposing technologies but positions on a single trade-off surface defined by latency, throughput, and cost. The Dataflow model makes this concrete by treating a batch job as a stream computation over a bounded input in a single global window, which is why the same engines and APIs increasingly express both. The architectural debate between Lambda and Kappa reduces to whether an independent batch recomputation is worth its dual-codebase burden, and the industry has drifted toward the single-path Kappa answer while retaining Lambda where reconciliation demands it. Beneath the architecture, the choice of execution model, micro-batch or event-at-a-time, sets a pipeline’s latency floor and recovery behavior, and the correctness machinery of event time, watermarks, windowing, and exactly-once processing is what makes streaming results trustworthy rather than merely fast.

    The most durable guidance is to resist treating either paradigm as a default. A workload should be placed on the continuum according to how fresh its result must be and what that freshness is worth, and the 2026 convergence toward unified engines and streaming lakehouse tables increasingly allows that decision to be deferred and revised without a rewrite. An engineer who understands the trade-off surface, rather than memorizing a preferred stack, is equipped to make that judgment correctly for each new dataset.

  • Data Contracts and Data Quality: Enforcing Reliability in Modern Pipelines

    Modern data platforms move records across many independent systems: an application emits an event, an ingestion job lands it in a lake, a transformation framework reshapes it, and a dashboard or a machine-learning model consumes the result. Every one of these handoffs is an implicit agreement about the shape and meaning of the data, and every silent violation of that agreement propagates downstream until a report is wrong or a model degrades. A data contract—a versioned, machine-readable agreement between the team that produces a dataset and the teams that consume it, specifying its schema, semantics, service-level guarantees, and quality expectations—turns that implicit agreement into an explicit, testable artifact. This post examines how data contracts and data quality enforcement work together to make pipelines reliable, and why the two ideas are complementary rather than interchangeable.

    The distinction that organizes the discussion is between prevention and detection. A data contract prevents bad data from entering a system by asserting expectations at the boundary where data is produced. Data observability, by contrast, detects problems after data has already landed, by monitoring freshness, volume, and schema drift and raising anomaly alerts. A mature platform uses both. The material below defines the contract as an artifact, presents a taxonomy of six data quality dimensions, surveys the enforcement engines that operate at the record, DataFrame, warehouse, and stream layers, and describes the patterns—shift-left validation, continuous-integration gates, pre-ingestion quarantine, circuit breakers, and dead-letter queues—that put a contract into force.

    Summary

    What this post covers: How data contracts and data quality enforcement combine to make data pipelines reliable, covering the contract as a versioned artifact, a taxonomy of quality dimensions, the enforcement engines available at each layer of a stack, and the patterns that put a contract into force at the boundary where data is produced.

    Key insights:

    • A data contract is a specification, not an engine; the Open Data Contract Standard (ODCS), currently at version 3.1.0 under the Linux Foundation’s Bitol project, describes the agreement in YAML, while separate tools enforce it at each layer.
    • The two historically competing specifications are consolidating: the older Data Contract Specification is being deprecated in favor of ODCS v3.1.0, with tooling support scheduled only until the end of 2026 (datacontract-specification.com, as of 2026).
    • Data quality decomposes into six measurable dimensions—completeness, uniqueness, validity, accuracy, consistency, and timeliness—of which accuracy and consistency are the hardest to enforce mechanically.
    • Enforcement is layered: Pydantic and Pandera validate at the record and DataFrame boundary, dbt tests and Great Expectations and Soda Core validate warehouse tables, and Confluent Schema Registry enforces schema evolution on Kafka streams.
    • Contracts prevent errors by asserting expectations at the producer boundary, whereas observability detects errors by monitoring reality after the fact; a reliable platform needs both.

    Main topics: The Data Contract as a First-Class Artifact, Six Dimensions of Data Quality, Where Enforcement Happens, Enforcement Patterns, Contracts and Observability.

    The Data Contract as a First-Class Artifact

    A data contract is a formal, versioned agreement between a data producer and its consumers. The producer is the system or team that emits a dataset—an application service publishing events, a database exposing a table, or an ingestion job writing files. The consumers are the downstream systems and teams that read that dataset: analytics models, dashboards, machine-learning features, and other services. Before contracts, the terms of that relationship lived in tribal knowledge and out-of-date documentation, so a producer could rename a column or change a unit of measurement without warning, and consumers discovered the break only when their output failed. A contract makes the terms explicit and machine-readable, so that a change which would violate them can be caught automatically.

    Four elements typically appear in a contract. The schema declares the fields, their types, and their nullability. The semantics describe what each field means, including units, allowed value sets, and business definitions, so that a field named revenue is unambiguous about currency and whether it is gross or net. The service-level agreement (SLA) states operational guarantees such as freshness, expected update frequency, and availability. The quality expectations encode testable rules—for example, that a primary key is unique or that a percentage column falls between zero and one hundred. Figure 1 shows how these four elements sit on the boundary between a producer and its consumers.

    The Producer-Consumer Contract Boundary Producer Service / table / ingestion job Data Contract Schema (types) Semantics SLA (freshness) Quality rules Analytics dashboards ML features training / serving Downstream services The contract is declared once and enforced wherever data crosses a boundary.

    A Standard for the Contract: ODCS

    Writing a contract in an ad-hoc format leaves every team to invent its own structure, so a shared specification is useful. The Open Data Contract Standard (ODCS) is a vendor-neutral YAML format that describes the agreement between a data producer and its consumers. Its current version is v3.1.0 (Bitol / Linux Foundation AI & Data, as of 2026). ODCS originated as PayPal’s internal data contract template, was open-sourced, and was donated to the Linux Foundation; it is now stewarded by Bitol, a Linux Foundation AI & Data incubation project licensed under Apache 2.0 that was formed on 30 November 2023 when the AIDA User Group and LF AI & Data joined forces (bitol.io, as of 2026). Bitol also stewards the Open Data Product Standard (ODPS), which has reached v1.0.0.

    An important point about the current landscape is that the ecosystem is consolidating around a single standard. A separate effort, the Data Contract Specification, is being deprecated and is converging on ODCS v3.1.0. Its maintainers advise users to migrate, and tooling support in the Data Contract CLI and Entropy Data is planned only until the end of 2026 (datacontract-specification.com, as of 2026). A team beginning with contracts in 2026 should therefore standardize on ODCS rather than the older specification. A minimal ODCS-style contract illustrates the artifact concretely.

    apiVersion: v3.1.0
    kind: DataContract
    id: orders-contract
    name: Orders
    version: 1.2.0
    status: active
    schema:
      - name: orders
        properties:
          - name: order_id
            logicalType: string
            required: true
            unique: true
          - name: customer_id
            logicalType: string
            required: true
          - name: order_total
            logicalType: number
            required: true
            quality:
              - rule: minimum
                mustBeGreaterThanOrEqualTo: 0
          - name: status
            logicalType: string
            required: true
            quality:
              - rule: enum
                values: [active, shipped, cancelled]
    slaProperties:
      - property: freshness
        value: 15
        unit: minute
    
    Key Takeaway: A contract is a specification, not an engine. ODCS declares what must hold in a portable YAML document; separate validation tools read that declaration—or an equivalent set of checks—and enforce it wherever data crosses a boundary. Keeping the specification and the enforcement engine distinct is what allows one contract to be enforced consistently across a warehouse, a DataFrame, and a stream.

    Treating the contract as versioned code has a direct engineering consequence: a change to the contract is a change to a file in version control, subject to review and to automated compatibility checks. This is what makes the shift-left patterns described later in this post possible, and it links data contracts to the broader practice of treating transformation logic as code, as covered in the guide to dbt for building transformation pipelines.

    Six Dimensions of Data Quality

    Before a contract can assert quality expectations, quality itself must be defined precisely enough to be measured. A widely used approach decomposes quality into distinct dimensions, each answering a different question about the data. The six dimensions below form a compact and practical taxonomy. Each is defined on first use, because the terms are often used loosely in practice.

    Six Dimensions of Data Quality Data Quality Completeness NOT NULL on customer_id Uniqueness no duplicate order_id Validity status in allowed set Accuracy matches system of record Consistency sum(lines)=order_total Timeliness landed within SLA window Red dimensions (accuracy, consistency) are the hardest to enforce mechanically.

    Completeness asks whether the expected data is present, with no missing values or absent records where they are required. A completeness check might assert that customer_id is never null, or that a daily table’s row count falls within an expected band. Uniqueness asks whether each real-world entity appears once, with no unintended duplicates; a typical check enforces primary-key uniqueness so that no order_id is repeated. Validity asks whether values conform to a defined format, type, range, or allowed set—the domain of the field. Examples include requiring that status belongs to a fixed set of allowed values, that an email address matches a regular expression, or that an age is not negative.

    Accuracy asks whether values correctly describe the real-world entity they represent. This is among the hardest dimensions to test in isolation, because it requires comparison against a trusted system of record rather than an internal rule; a value can be valid and unique yet still wrong. Consistency asks whether values agree across systems, tables, and over time, with no contradictions—for instance, that the sum of an order’s line items equals its recorded total, or that a customer’s country is identical in every table. Timeliness asks whether data is fresh and available within its expected latency or SLA window; a timeliness check verifies that the maximum event timestamp is within the last few minutes or that a partition landed on schedule.

    Accuracy and consistency are the two dimensions that resist mechanical enforcement most strongly, because they depend on external reference points and cross-system reconciliation rather than a self-contained rule. A contract can assert them, but verifying them often requires a comparison the pipeline cannot perform on its own. A seventh dimension, integrity—referential integrity, meaning that foreign keys resolve to existing rows—is sometimes added; it can be treated as a specialized form of consistency across tables.

    Caution: A record can satisfy completeness, uniqueness, and validity while still failing accuracy. A well-formed, unique, non-null value drawn from the allowed set can nonetheless misrepresent reality. Passing structural checks is necessary but not sufficient for correctness, which is why contracts pair schema rules with reconciliation against a system of record wherever one exists.

    Where Enforcement Happens: Four Layers and Their Engines

    A single contract is enforced at several points, because data changes form as it moves. It arrives as individual records at an application boundary, is assembled into in-memory tables (DataFrames) for processing, is materialized as warehouse tables, and travels as messages on streams. Each form has a matching class of validation engine. The essential distinction—already stated for the contract itself—applies here too: the specification declares what must hold, and each engine enforces it in the representation it understands.

    Tooling by Enforcement Layer Tool Record DataFrame Warehouse Stream Pydantic v2.13.4 Pandera 0.32.1 dbt tests 1.11.x Great Expectations 1.18.2 Soda Core 4.16.0 Schema Registry ODCS v3.1.0 (spec) Engine enforces at this layer Specification describes all layers; needs an engine to enforce

    Record and DataFrame Boundaries

    At the application boundary, data arrives one record at a time—an incoming API request, a single event to be published. Pydantic validates and parses data at this record level. Its current version is v2.13.4 (pydantic/pydantic releases, as of 2026). The validation engine lives in a component called pydantic-core, written in Rust through the pyo3 binding layer, which delivers roughly a fivefold to fiftyfold performance improvement over version 1 depending on the workload (pydantic.dev, as of 2026). Pydantic excels at fast per-record validation at ingestion and API edges, but it operates one object at a time and is not designed for table-scale statistical checks.

    Once records are assembled into a DataFrame—an in-memory tabular structure—the relevant unit of validation becomes the column and the table rather than the single object. Pandera provides schema-based, statistical validation for DataFrames. Its current version is 0.32.1 (unionai-oss/pandera releases, as of mid-2026). Pandera 0.19.0 added Polars validation, and by version 0.29 (January 2026) a single schema definition could validate data across pandas, Polars, Dask, Modin, PySpark, and Ibis; a Narwhals-powered backend introduced in 0.32.0 adds lazy validation across Polars, Ibis, and PySpark SQL. A Pandera schema expresses column-level expectations directly.

    import pandera as pa
    from pandera import Column, Check
    
    schema = pa.DataFrameSchema({
        "order_id":    Column(str, unique=True, nullable=False),
        "customer_id": Column(str, nullable=False),
        "order_total": Column(float, Check.ge(0)),
        "status":      Column(str, Check.isin(["active", "shipped", "cancelled"])),
    })
    
    # Raises SchemaError on the first violation, or collects all
    # violations when lazy=True.
    validated = schema.validate(df, lazy=True)
    

    Warehouse Tables

    Inside the warehouse, checks run against materialized tables in SQL. Three engines are common here. dbt tests co-locate assertions with the transformation models that produce a table. dbt Core’s current version is 1.11.12 (dbt-core GitHub releases, as of 2026-07-01), with a Rust-based v2.0.0-alpha in development as the foundation for the dbt Fusion engine. dbt distinguishes data tests—generic and singular assertions that run against materialized data—from native unit tests, which validate SQL logic and were introduced in v1.8; in v1.9 and later, dbt test --resource-type test runs data tests while excluding unit tests. Because dbt tests only cover what dbt materializes, they do not guard the ingestion boundary or streams. The role of dbt in the transformation layer is treated fully in the guide to dbt transformation pipelines.

    Great Expectations (GX Core) is a dedicated validation framework built around a reusable library of expressive assertions called Expectations, together with automatic profiling and human-readable validation reports called Data Docs. Its current version is 1.18.2 (Great Expectations changelog, as of 2026-06-26); GX Core 1.0 was a major API redesign that separated the open-source GX Core from the commercial GX Cloud. It supports SQL, Spark, and pandas backends. Its strength is the breadth and reusability of its Expectation library; historically its weakness has been a heavier setup and a steeper learning curve. Figure 5 traces its validation flow.

    Great Expectations Validation Flow Data table / DataFrame Expectation Suite declared rules Validator runs the suite Validation Result PASS promote data FAIL halt / quarantine Data Docs human-readable report

    Soda Core is a second dedicated framework, known for concise checks and a continuous-integration-friendly command-line interface. Its current version is 4.16.0, released 29 June 2026, and it requires Python 3.10 or later (Soda Core release notes, as of 2026-06-29). A notable change is that Soda Core version 4 makes data contracts the default way to define quality rules—a breaking change that moves away from the older SodaCL “checks” syntax toward a contract-based syntax. A concise Soda check reads close to natural language.

    checks for orders:
      - row_count > 0
      - missing_count(customer_id) = 0
      - duplicate_count(order_id) = 0
      - invalid_count(status) = 0:
          valid values: [active, shipped, cancelled]
      - freshness(order_time) < 15m
    

    Streams

    On a Kafka stream, the contract is the message schema, and the enforcement point is a schema registry—a service that stores schemas and rejects producers whose schema is incompatible with the registered version. Confluent Schema Registry supports Avro, Protobuf, and JSON Schema (referred to as JSON_SR) out of the box on both Confluent Platform and Confluent Cloud (docs.confluent.io, as of 2026). It governs schema evolution through compatibility types: BACKWARD (the default), BACKWARD_TRANSITIVE, FORWARD, FORWARD_TRANSITIVE, FULL, FULL_TRANSITIVE, and NONE. BACKWARD is the default because it allows consumers using the new schema to read data written with the previous schema, which lets a consumer rewind to the start of a topic. Confluent recommends BACKWARD_TRANSITIVE for Protobuf, because adding new message types is not forward compatible, whereas Avro was designed with schema evolution in mind (docs.confluent.io, as of 2026). A schema registry enforces the shape and evolution of messages, not their semantic quality; it will not catch a null in a field that is technically nullable or a value out of an expected range. Streaming schema enforcement pairs naturally with change-data-capture pipelines, as discussed in the guide to change data capture with Debezium and Kafka, and with the consumer-side handling covered in the Kafka consumer implementation guide.

    The following table summarizes what each engine does best and where its boundary lies. Every version cited carries its source and date in the prose above.

    Tool (version) Layer Best at Boundary
    Pydantic (v2.13.4) Application record Fast per-record validation and parsing at API and ingestion edges One object at a time; not for table-scale statistical checks
    Pandera (0.32.1) DataFrame Schema-based statistical validation across pandas, Polars, PySpark, Ibis In-process; not a warehouse or catalog-level gate
    dbt tests (1.11.x) Transformation (in-warehouse) Tests co-located with models; data tests plus unit tests since v1.8 Only tests what dbt materializes; not the ingestion boundary or streams
    Great Expectations (1.18.2) Warehouse / batch tables Rich reusable Expectation library; profiling; Data Docs Heavier setup; historically steeper learning curve
    Soda Core (4.16.0) Warehouse + contracts Concise checks; contracts as default in v4; CI-friendly CLI v4 contract migration is a breaking change from SodaCL checks
    Confluent Schema Registry Streaming (Kafka) Schema-as-contract; compatibility enforcement for Avro/Protobuf/JSON_SR Enforces shape and evolution, not semantic quality
    ODCS v3.1.0 (Bitol/LF) Specification / governance Vendor-neutral YAML linking schema, SLA, quality, and ownership A specification, not an engine; needs a tool to enforce it

     

    Tip: Match the engine to the representation. Validate individual records with Pydantic at the edge, DataFrames with Pandera in processing jobs, warehouse tables with dbt tests or Great Expectations or Soda Core, and stream messages with a schema registry. A single ODCS contract can act as the shared source of truth that these separate engines each enforce in their own layer.

    Enforcement Patterns: Shift-Left, Gates, and Dead-Letter Queues

    Having an engine is not the same as having a strategy for where and when it runs. Several established patterns place enforcement at different points in the data’s journey, trading availability against correctness in different ways.

    Shift-Left at the Producer Boundary

    Shift-left is the practice of moving validation as early as possible—toward the point where data is produced—rather than catching problems late in the pipeline. The term borrows from software testing, where moving tests earlier (“to the left” on a left-to-right timeline) reduces the cost of fixing defects. Applied to data, shift-left means validating at the application or ingestion boundary so that non-conforming data never enters the warehouse at all. This is the cheapest place to catch an error, because the cost of a defect rises as it travels downstream: a bad value caught at the producer affects nothing, whereas the same value caught at the BI or ML layer may have already corrupted reports, retrained a model, or been copied into many derived tables. Figure 3 depicts this rising cost.

    Shift-Left: Cost to Fix Rises Downstream Cost to fix low Producer validate here Ingestion Warehouse BI / ML most expensive Bar heights are illustrative of relative cost, not measured values.

    Continuous-Integration Gates on Contract Changes

    Because a contract is a file under version control, a proposed change to a producer’s schema arrives as a pull request. A continuous-integration (CI) gate is an automated check that runs on that pull request and blocks the merge if the change would break the contract. Two checks matter here: validating that the contract file itself is well formed, and running a backward-compatibility check that determines whether existing consumers can still read data produced under the new schema. This mirrors exactly what a schema registry does at runtime for streams, moved earlier to the moment of code review. A breaking change—removing a required field, narrowing a type—fails the gate and cannot merge until the producer and consumers agree on a migration. Figure 6 shows the contract advancing through versions with a compatibility check at each transition.

    Contract Versioning and Compatibility Checks v1.0 baseline v1.1 add optional field v2.0 drop required field compatible CI passes, merge breaking CI blocks merge Requires coordinated migration before consumers can adopt v2.0

    Pre-Ingestion Gates and Circuit Breakers

    When validation cannot happen strictly at the producer—because the producer is a third party, or the data arrives as files—a pre-ingestion gate places incoming data in a staging area first and requires a validation step to pass before the data is promoted to a production table. The gate fails closed: if the check does not pass, the data does not advance. This quarantines suspect data rather than exposing it to consumers.

    A related pattern is the circuit breaker, which halts a running pipeline when a contract check fails beyond a chosen threshold, rather than allowing bad data to propagate. The name is borrowed from electrical engineering, where a breaker interrupts a circuit to prevent damage. In a data pipeline hosted by an orchestrator, a failed check fails the orchestrator task and stops downstream jobs from running. This deliberately trades availability for correctness: a stalled pipeline is often preferable to a fast one that delivers wrong answers, because a visible delay prompts investigation whereas a silent error can persist unnoticed for days. Orchestrators such as those covered in the Apache Airflow orchestration guide are the natural host for both pre-ingestion gates and circuit breakers, because they already model tasks, dependencies, and failure propagation. A validation task placed upstream of a load task allows the orchestrator to skip or fail the load automatically when the check does not pass, so the circuit-breaker behavior falls out of the dependency graph rather than requiring bespoke error handling.

    Dead-Letter Queues for Streaming

    In a streaming pipeline, halting the entire stream because a single record fails a check would be too blunt, since one malformed message would block every well-formed one behind it. A dead-letter queue (DLQ) solves this by routing records that fail schema or quality checks to a separate queue for inspection and later replay, while valid records continue on the main stream. This isolates failures at the level of the individual record rather than the whole pipeline, and it preserves the malformed records for diagnosis instead of discarding them. Figure 4 shows a stream splitting into a pass branch and a dead-letter branch, with a replay path back into the pipeline once the underlying problem is fixed.

    Streaming Enforcement with a Dead-Letter Queue Producer event stream Schema + quality check Main topic valid records flow on PASS Dead-letter queue failed records isolated FAIL Inspect & fix then replay replay after fix

    Key Takeaway: The patterns form a graduated response. Shift-left prevents most defects at the source; CI gates block breaking schema changes before they merge; pre-ingestion gates quarantine suspect batches; circuit breakers halt a batch pipeline that has already ingested bad data; and dead-letter queues isolate individual failing records on a stream without stopping the whole flow. Each pattern trades some availability for correctness in a way suited to its layer.

    Contracts and Observability: Prevention Versus Detection

    Data contracts are frequently discussed alongside data observability, and the two are sometimes conflated. They address the same goal—reliable data—from opposite directions. A contract is a mechanism of prevention: it asserts expectations at the boundary and refuses data that violates them, so a defect is stopped before it enters the system. Observability is a mechanism of detection: it monitors the data that has already landed—tracking freshness, volume, distribution, and schema drift—and raises an alert when reality departs from the norm. Observability finds problems the contract did not anticipate, including gradual distribution shifts and upstream failures that produce technically valid but unusual data. Figure 8 places the two side by side over a shared pipeline.

    Contracts Prevent, Observability Detects Contracts = Prevention Assert expectations at the boundary Refuse data that violates the schema, SLA, or quality rules Acts before data enters the system Observability = Detection Monitor freshness, volume, drift Alert when reality departs from the expected norm Acts after data has landed Shared pipeline produce → ingest → transform → serve A mature platform runs both: prevention at the boundary, detection over what lands.

    Neither mechanism substitutes for the other. A contract cannot anticipate every failure mode, particularly slow statistical drift in otherwise valid data; observability catches those. Observability, on its own, only tells a team that something has already gone wrong, often after consumers have been affected; a contract prevents the class of failures it can express. The two belong together in a mature stack. Quality gates also interact with the storage layer, because a table’s schema evolution is itself a contract concern—an area explored in the comparison of Iceberg, Delta Lake, and Hudi table formats and in the discussion of typing at the file level in the guide to Parquet and Apache Arrow internals. An end-to-end pipeline where these gates would apply in practice is described in the walkthrough from InfluxDB to AWS Iceberg with Telegraf.

    The Business Case for Enforcement

    The motivation for this discipline is that poor data quality carries a measurable cost. A frequently cited estimate holds that poor data quality costs organizations at least 12.9 million US dollars per year on average; this figure comes from Gartner’s 2020 Magic Quadrant for Data Quality Solutions, based on a survey of 154 reference customers across 16 vendors (Gartner, as of 2020). It should be read as a 2020 estimate rather than a current measurement, and organizations vary widely, but it captures the order of magnitude at stake. The cost is not only financial. Erroneous data erodes the trust that consumers place in a dataset, and once a dashboard has been visibly wrong, downstream teams begin to second-guess every figure it produces, which slows decisions and encourages the growth of parallel, unofficial data copies. Enforcement at the boundary is a way to protect that trust as much as it is a way to avoid direct cost. Adoption of data contracts is growing as teams formalize the producer-consumer relationship, although a precise adoption figure is not available from a reliable primary survey. The direction of travel is clear from the standards themselves: the consolidation of the Data Contract Specification into ODCS v3.1.0 under the Linux Foundation signals a maturing field converging on shared conventions.

    Frequently Asked Questions

    What is the difference between a data contract and a database schema?

    A database schema declares field names, types, and nullability—the structural shape of a table. A data contract is broader: it wraps the schema together with semantics (what each field means, including units and allowed value sets), a service-level agreement (freshness and availability guarantees), and testable quality expectations, and it is versioned as an explicit agreement between the producing team and its consumers. In short, a schema is one component of a contract, and the contract adds meaning, guarantees, and ownership on top of structure.

    Do I need both a schema registry and a tool like Great Expectations?

    They cover different concerns and are often used together. A schema registry, such as Confluent Schema Registry, enforces the shape and compatible evolution of messages on a stream, rejecting a producer whose schema is incompatible with the registered version. It does not check semantic quality such as null values in nullable fields, out-of-range numbers, or freshness. A tool like Great Expectations or Soda Core enforces those quality expectations against tables or DataFrames. A streaming platform that also cares about value-level quality therefore benefits from both.

    Which data quality dimensions are hardest to enforce automatically?

    Accuracy and consistency are the hardest. Accuracy asks whether a value correctly describes the real-world entity it represents, which requires comparison against a trusted system of record rather than a self-contained rule; a value can be well-formed, unique, and within the allowed set yet still be wrong. Consistency asks whether values agree across systems and over time, which requires cross-system reconciliation. Completeness, uniqueness, validity, and timeliness are comparatively straightforward to express as mechanical checks.

    Should a team standardize on ODCS or the Data Contract Specification in 2026?

    A team beginning in 2026 should standardize on the Open Data Contract Standard (ODCS), currently at version 3.1.0 under the Linux Foundation’s Bitol project. The alternative Data Contract Specification is being deprecated and is converging on ODCS, with its tooling support in the Data Contract CLI and Entropy Data planned only until the end of 2026 (datacontract-specification.com, as of 2026). Choosing the surviving standard avoids a forced migration later.

    References

    Conclusion

    Data reliability is achieved not by a single tool but by a discipline that combines an explicit artifact with layered enforcement. The data contract turns the implicit producer-consumer agreement into versioned, machine-readable code, and the Open Data Contract Standard v3.1.0 provides a vendor-neutral way to express it; the consolidation of the older Data Contract Specification into ODCS marks a field settling on shared conventions. Quality itself becomes testable once it is decomposed into completeness, uniqueness, validity, accuracy, consistency, and timeliness, with the understanding that accuracy and consistency resist mechanical checks and require external reference points.

    The contract is then enforced wherever data changes form: Pydantic and Pandera at the record and DataFrame boundary, dbt tests and Great Expectations and Soda Core in the warehouse, and a schema registry on the stream. The patterns that put enforcement into force—shift-left validation at the producer, continuous-integration gates on contract changes, pre-ingestion quarantine, circuit breakers, and dead-letter queues—each trade some availability for correctness in the way best suited to their layer. Finally, contracts and observability are complementary rather than interchangeable: contracts prevent the failures they can express at the boundary, while observability detects the anomalies they cannot anticipate. A platform that runs both, over a contract treated as a first-class artifact, is one whose data consumers can trust.

  • Apache Parquet and Apache Arrow Internals: Columnar Storage, Encoding, and Predicate Pushdown

    Apache Parquet and Apache Arrow are the two most widely deployed columnar data formats in modern analytics, yet most engineers who depend on them daily treat them as opaque. A query runs against a Parquet file through Spark, DuckDB, Polars, or pandas, the result returns quickly, and the reasons remain hidden. This post opens the black box. It describes what the bytes of a Parquet file actually look like on disk, how encoding and compression stack into a layered pipeline, how metadata and statistics let a reader skip most of the data it never needs, and how Apache Arrow provides the complementary in-memory format that lets independent engines exchange columns without paying a serialization cost. The goal is a mechanistic understanding that translates directly into tuning decisions: row-group sizing, encoding choice, compression codec, page index, and Bloom filters.

    The two projects are designed to work together. Parquet is a space-efficient format optimized for durable storage on disk or in object storage. Arrow is a compute-efficient format optimized for processing in memory. A reader decodes Parquet pages into Arrow arrays, operates on them in a CPU-friendly layout, and hands them to another library through raw memory pointers. Understanding both halves, and the bridge between them, explains why a column-oriented analytical stack behaves the way it does.

    Summary

    What this post covers: How Apache Parquet physically stores columnar data and how Apache Arrow represents the same data in memory, with a focus on encoding, compression, metadata-driven pushdown, and zero-copy interchange between engines.

    Key insights:

    • A Parquet file is a nested hierarchy of file, row group, column chunk, and page, with a footer holding schema and statistics; the default row group is 128 MB and the default page is 1 MB (parquet.apache.org configurations, as of 2026-07-02).
    • Encoding (structural, format-aware, such as dictionary and delta encodings) and compression (byte-level, general-purpose, such as Snappy or Zstd) are two separate layers that stack, and the delta encodings are a DataPageV2 feature.
    • Predicate pushdown prunes I/O in three tiers — row-group statistics, the Page Index, and the Split Block Bloom Filter (256-bit blocks) — each covering a case the previous tier cannot (apache/parquet-format BloomFilter.md, as of 2026-07-02).
    • Apache Arrow is the in-memory counterpart to Parquet, and its C Data Interface lets libraries in the same process share column buffers with no copy, relying only on a stable C ABI (arrow.apache.org, as of 2026-07-02).

    Main topics: Why columnar storage exists, Anatomy of a Parquet file on disk, Encoding and compression, Metadata and pushdown, Apache Arrow and the Parquet-to-Arrow bridge, Practical guidance for tuning.

    Why columnar storage exists

    A data file must choose an order in which to write the values of a table. A row-oriented format stores all fields of the first record together, then all fields of the second record, and so on. This layout suits transactional workloads that read or write entire records one at a time. An analytical workload behaves differently. It scans a small number of columns across a very large number of rows, computing aggregates, filters, and joins. For such a query, a row-oriented file forces the reader to touch every field of every record even when only two of forty columns are relevant.

    A column-oriented, or columnar, format inverts the layout. It stores all values of the first column together, then all values of the second column, and so on. A query that reads two columns reads only those two contiguous regions and ignores the rest of the file. This property, reading only the requested columns, is called projection pushdown, and it is essentially free in a columnar format because the requested data is already grouped.

    Columnar layout produces a second benefit that compounds the first. Values within a single column share a data type and often share statistical structure — a timestamp column tends to increase monotonically, a category column repeats a small set of strings, a sensor column clusters around a mean. Grouping like values together lets the format apply type-aware encodings and lets general-purpose compressors find far more redundancy than they could in an interleaved row layout. The figure below contrasts the two layouts for a small table.

    Row layout versus column layout for the same table Row-oriented (record at a time) Column-oriented (Parquet, Arrow) id 1 city A temp 21 id 2 city A temp 22 id 3 city B temp 20 id 4 city B temp 19 A temperature scan must still read id and city bytes id 1 id 2 id 3 id 4 city A city A city B city B temp 21 temp 22 temp 20 temp 19 A temperature scan reads only the boxed region Like values grouped together compress better Two formats, two responsibilities Parquet: space-efficient at rest (on disk / object store) Arrow: compute-efficient in memory (CPU / GPU) A reader decodes Parquet pages into Arrow arrays and processes them there

    This division of labor is deliberate. A durable storage format optimizes for size, because bytes on disk or in object storage cost money and network transfer time. An in-memory format optimizes for processing speed, because a CPU or GPU benefits from predictable, contiguous, aligned buffers. Apache Parquet fills the first role and Apache Arrow fills the second. The remainder of this post examines each in turn, then the bridge that connects them.

    Anatomy of a Parquet file on disk

    A Parquet file is a self-describing container built from a strict hierarchy. Understanding that hierarchy is the foundation for every tuning decision that follows, because each level maps to a unit of I/O or a unit of metadata.

    The file, row group, column chunk, and page

    At the top is the file. A file is divided horizontally into row groups. A row group is a partition of the rows — a contiguous band of records — and it is the largest unit that can be processed independently. Within a row group, each column is stored as a column chunk, which holds every value of that one column for that band of rows. Each column chunk is in turn divided into pages. A page is the smallest unit that must be read and decompressed in full to access any record inside it, and each page is encoded and compressed independently. The default row group size is 128 MB (the parquet.block.size configuration is 134217728 bytes) and the default page size is 1 MB (the parquet.page.size configuration is 1048576 bytes), according to the Parquet configuration documentation (parquet.apache.org, as of 2026-07-02).

    The file is framed by a four-byte magic value, PAR1, at both the beginning and the end. The trailing region before the closing magic is the footer, which holds the FileMetaData: the schema, the list of row groups, the metadata and statistics for every column chunk, and the byte offsets that locate optional structures such as the Page Index and Bloom filters. A reader opens a Parquet file by seeking to the end, reading the footer, and using it as a map to the rest of the file. The figure below shows this layout.

    Parquet file layout, top to bottom PAR1 (4-byte magic header) Row Group 1 (default 128 MB band of rows) Column Chunk: id Dictionary Page (optional) Data Page Data Page page = atomic read / decompress unit (~1 MB) Column Chunk: city Dictionary Page Data Page Data Page Column Chunk: temp Data Page Data Page Row Group 2 … N (same column-chunk structure) Footer: FileMetaData (the map to everything above) Schemacolumn types + nesting Row-group / chunkstats: min, max, nulls Offsetsto Page Index + Bloom Key-value metadatae.g. Arrow schema Readers seek to the end first, parse the footer, then read only the pages a query needs 4-byte footer length + PAR1 magic footer PAR1 (4-byte magic footer)

    The Parquet specification defines this structure in two parts that are read together: a prose format document and a Thrift definition file, parquet.thrift, that specifies the exact metadata structures (apache/parquet-format README, as of 2026-07-02). The latest format specification version is 2.12.0 (apache/parquet-format CHANGES.md, as of 2026-07-02). The format continues to evolve — a Variant type for semi-structured data was announced in February 2026 (parquet.apache.org blog, as of 2026-07-02).

    Data page versions

    Pages come in several types. A DictionaryPage stores the distinct values of a dictionary-encoded column. A DataPage carries the actual values. There are two versions of the data page. The original DataPage (V1) stores repetition levels, definition levels, and encoded values together in one compressed block. DataPageV2 separates the repetition and definition levels from the values, allows those levels to remain uncompressed while the values are compressed, and gives per-page control over whether compression is applied. This separation is what unlocks the delta encoding family described in the next section. An IndexPage type is reserved in the specification. The figure below contrasts the two data page versions.

    DataPage V1 versus DataPageV2 DataPage (V1) Page header (value count, encoding) One compressed block: repetition levels + definition levels + encoded values, compressed together Levels cannot be read without decompressing the whole page DataPageV2 Page header (with row/null counts) Repetitionlevels (raw) Definitionlevels (raw) Encoded valuescompressed separately (optional per page) Levels stay uncompressed and readable; enables the delta encoding family Repetition and definition levels encode nested and optional fields (the Dremel model) Definition level = how deeply a value is defined (nulls); repetition level = where a repeated field restarts

    Repetition and definition levels are the mechanism by which Parquet represents nested and repeated fields in a flat columnar store. This technique originates in the Dremel system described by Melnik and colleagues (VLDB 2010), the foundational paper for Parquet’s nested encoding. A definition level records how many of a column’s optional or nested ancestors are actually present for a given value, which is how nulls at any nesting depth are represented without storing placeholder values. A repetition level records at which nesting level a repeated field begins a new list. Together the two levels let a reader reconstruct arbitrarily nested records from flat columns.

    Encoding and compression

    Two distinct byte-reduction layers operate inside a Parquet column chunk, and conflating them is a common source of confusion. Encoding is a structural, format-aware transformation applied to the values of one column. Compression is a byte-level, general-purpose transformation applied to the already-encoded bytes of a page. The two stack: values are first encoded, then the encoded output is compressed. A reader reverses the order, decompressing a page and then decoding its values.

    Encoding and compression are two stacked layers Write path (top) and read path (bottom) traverse the layers in opposite order Raw valuestyped column data Encodedictionary / delta / RLE(structural, cheap) CompressSnappy / Zstd / Gzip(byte-level, general) Pageon disk Pageon disk Decompress Decode Valuesinto Arrow arrays Encoding exploits column structure; compression removes remaining byte redundancy. They are independent choices.

    The encoding families

    PLAIN encoding is the baseline. It writes values in their natural fixed-width or length-prefixed form with no attempt at reduction, and it exists as a fallback when no structural pattern can be exploited.

    Dictionary encoding is the workhorse for columns with a limited number of distinct values, such as country codes, status strings, or category labels. The writer builds a dictionary of distinct values, stored in a DictionaryPage, and replaces each value in the data pages with a small integer index into that dictionary. Those indices are then written with the RLE_DICTIONARY encoding, which has enum value 8 in the specification, is valid for all physical types, and is the default for string columns (apache/parquet-format Encodings.md, as of 2026-07-02). If the dictionary grows beyond the configured dictionary page size — the default dictionary page size is 1 MB (parquet.apache.org configurations, as of 2026-07-02) — the writer abandons the dictionary for that column chunk and falls back to PLAIN. One dictionary page is written per column per row group.

    Dictionary encoding of a low-cardinality column Original values “London” “London” “Berlin” “London” “Tokyo” “Berlin” Dictionary Page 0 = “London” 1 = “Berlin” 2 = “Tokyo” distinct values stored once RLE_DICTIONARY page 0 0 1 0 2 1 small integers, run-length + bit-packed The same RLE / bit-packing hybrid also encodes definition levels, repetition levels, and dictionary indices

    The RLE / bit-packing hybrid encoding underlies dictionary indices and also carries the repetition and definition levels. It combines run-length encoding, which represents a repeated value as a value-plus-count pair, with bit-packing, which stores small integers using only as many bits as their range requires rather than a full 32 or 64 bits. The encoder switches between the two modes according to which is smaller for each run.

    The delta encoding family targets numeric and byte-array columns and is a DataPageV2 feature, not used for V1 pages (parquet.apache.org encodings, as of 2026-07-02). DELTA_BINARY_PACKED, enum value 5, applies to INT32 and INT64 columns and stores the differences between consecutive values rather than the values themselves (apache/parquet-format Encodings.md, as of 2026-07-02). For a sorted or monotonic column such as a timestamp or an auto-incrementing identifier, those differences are small and highly repetitive, so the encoded output is far smaller than the raw integers. DELTA_BYTE_ARRAY, enum value 7, applies to BYTE_ARRAY and FIXED_LEN_BYTE_ARRAY columns; it encodes the lengths of the byte arrays using DELTA_BINARY_PACKED and then concatenates the bytes, and it is preferred over PLAIN for byte arrays (parquet.apache.org encodings, as of 2026-07-02). A related variant, DELTA_LENGTH_BYTE_ARRAY, separates the length stream from the data stream for strings without shared prefixes.

    BYTE_STREAM_SPLIT, enum value 9, applies to INT32, INT64, FLOAT, DOUBLE, and FIXED_LEN_BYTE_ARRAY columns (apache/parquet-format Encodings.md, as of 2026-07-02). It does not reduce size by itself. Instead it scatters the K bytes of each value into K separate streams, placing all the first bytes together, then all the second bytes, and so on. Floating-point values that cluster around a common magnitude share high-order bytes, and grouping those bytes together gives the downstream compressor much more redundancy to exploit (parquet.apache.org encodings, as of 2026-07-02). This is a clear illustration of the encoding-then-compression stack: the encoding rearranges bytes so that the compression layer performs better.

    The compression codecs

    After encoding, each page is compressed with a block-level, general-purpose codec. The supported codecs are UNCOMPRESSED, SNAPPY, GZIP, LZ4 and LZ4_RAW, ZSTD, and BROTLI. Because each page is compressed independently, a reader can decompress exactly the pages a query touches and no others. The choice among codecs is a trade-off between compression ratio and processing speed, and the appropriate choice is workload-dependent rather than fixed.

    Codec Speed Compression ratio Typical role
    Snappy Fast Modest Long-time default, hot data
    Zstd Competitive, tunable level Strong Increasingly the recommended default
    Gzip Slow Highest Archival / cold data
    LZ4 / Brotli Varies Varies Specialized speed or ratio needs

     

    Caution: Specific compression ratios such as “Zstd is 40 percent smaller than Snappy” are not meaningful as universal figures. The ratio depends heavily on the column’s data distribution and on which encodings were applied first. Snappy provides fast compression and decompression at a modest ratio; Gzip provides the highest ratio at the lowest speed; Zstd provides a tunable level with strong ratios at competitive speed and is increasingly adopted as a default. Measure on a representative sample of the actual dataset rather than relying on published benchmarks.

    Metadata, statistics, and pushdown

    The performance of an analytical scan depends less on how fast the reader processes data than on how much data it manages to avoid reading at all. Parquet supports this avoidance through metadata stored in the footer and in optional index structures. Two mechanisms operate together: projection pushdown selects which columns to read, and predicate pushdown selects which rows — more precisely, which row groups and pages — to read.

    Projection pushdown was described earlier. Because each column is stored as an independent column chunk, a reader consults the footer to find the byte ranges of the requested columns and reads only those ranges. No special index is required; the columnar layout makes it free.

    Predicate pushdown is more involved and operates in three pruning tiers, each addressing a case the previous tier cannot handle. The figure below shows the tiers as a funnel that progressively narrows the data a query must read.

    Predicate pushdown: three tiers of pruning Query: WHERE user_id = 90422 AND event_time > ‘2026-06-01’ Tier 1 — Row-group statistics (footer) Each row group stores min / max / null count per column in FileMetaData. If event_time max < ‘2026-06-01’, skip the entire 128 MB row group without opening it. Tier 2 — Page Index (ColumnIndex + OffsetIndex) ColumnIndex holds min / max per page; OffsetIndex maps row index to byte offset, so a reader skips to and reads only matching pages. Tier 3 — Split Block Bloom Filter For high-cardinality equality (user_id), where min / max ranges are useless. Answers “definitely no” or “probably yes”. Read only surviving pages a small fraction of the file Each tier handles a predicate the tier above cannot prune

    The three pruning tiers

    The first tier is row-group statistics. The footer stores, for each column chunk, the minimum value, the maximum value, and the null count. When a query filters on a column, the reader compares the predicate against these statistics. If a row group’s minimum and maximum for the filtered column cannot possibly satisfy the predicate, the reader skips the entire row group — up to 128 MB of data — without reading any of its pages. This tier is coarse but very cheap, since the statistics are already in the footer the reader parsed on open.

    The second tier is the Page Index, which brings the same idea down to page granularity. The Page Index was introduced in Parquet 1.11 and is used by Spark 3.2.0 and later (CERN Databases blog and parquet-format PageIndex.md, as of 2026-07-02). It consists of two structures. The ColumnIndex stores the minimum and maximum values for each page in a column chunk, letting the reader identify exactly which pages could contain matching rows. The OffsetIndex maps a row index to the byte offset of the page that contains it, so once the ColumnIndex has selected the relevant pages, the reader seeks directly to them. Together these let a reader skip individual pages within a column chunk that survived the row-group filter.

    The third tier is the Bloom filter, which handles a case the range-based tiers cannot. Minimum and maximum statistics prune well when the filtered values fall outside a range, but they are useless for equality predicates on high-cardinality columns whose values are scattered across the whole domain. A filter such as user_id = 90422 will match the min/max range of almost every row group even though only a handful of rows qualify. A Bloom filter is a compact probabilistic structure that answers set membership. Parquet supports exactly one representation, the Split Block Bloom Filter (SBBF), whose block size is 256 bits, arranged as eight contiguous 32-bit words (apache/parquet-format BloomFilter.md, as of 2026-07-02). It answers a membership query with either “definitely not present” or “probably present” — it produces no false negatives, only occasional false positives (apache/parquet-format BloomFilter.md, as of 2026-07-02). When the answer is “definitely not present,” the reader skips the row group. Dictionary pages can serve a similar membership-pruning role, since a value absent from a column chunk’s dictionary cannot appear in that chunk.

    Key Takeaway: The three tiers are complementary, not redundant. Row-group statistics prune large blocks cheaply, the Page Index refines pruning to individual pages, and the Bloom filter handles high-cardinality equality that range statistics cannot. Enabling a Bloom filter on a low-cardinality column wastes space, because the range and dictionary tiers already prune it well.

    Apache Arrow and the Parquet-to-Arrow bridge

    Parquet describes data at rest. Once a reader decodes a Parquet page, the values must live somewhere in memory in a form suitable for computation. That form is Apache Arrow. Arrow defines a language-independent, in-memory columnar layout for both flat and nested data, designed for efficient analytics on CPUs and GPUs (arrow.apache.org Columnar.html, as of 2026-07-02). The latest Apache Arrow release is 24.0.0, published on 2026-04-21, comprising 259 resolved issues and 325 commits from 57 contributors over roughly three months (arrow.apache.org blog, as of 2026-07-02).

    Where Parquet optimizes for size on disk, Arrow optimizes for access speed in memory. An Arrow array stores a column’s values in a contiguous buffer with a separate validity bitmap indicating which entries are null. This layout is predictable and aligned, so a processor can iterate over it sequentially and apply vectorized instructions that operate on many values per cycle. The Parquet-to-Arrow bridge decodes Parquet pages directly into Arrow arrays: a library such as pyarrow reads a Parquet file and produces Arrow Tables, translating the on-disk encodings into the in-memory layout in one step.

    Zero-copy and the C Data Interface

    The most consequential property of Arrow is that independent libraries can agree on the exact byte layout of a column and therefore share it without copying. When a query engine, a dataframe library, and a machine-learning framework all represent a column as the same Arrow buffer, one can hand the column to another by passing a pointer rather than serializing, transmitting, and deserializing the data. This is the mechanism the Arrow C Data Interface formalizes. It enables zero-copy sharing between runtimes within a single process through memory pointers, and its only dependency is a stable C application binary interface (ABI) — a fixed set of C structures that every participant understands (arrow.apache.org CDataInterface.html, as of 2026-07-02).

    Arrow C Data Interface: sharing columns with no copy One operating-system process, one shared memory space Arrow column buffers values + validity bitmap held once in memory Query enginee.g. DuckDB Dataframe libe.g. Polars pandas / MLframework Each library reads the same buffers via a pointer (C ABI struct) — no copy, no serialization Without a shared in-memory standard Library A serializeto bytes copy + parsedeserialize Library B Every handoff pays CPU and memory cost that the C Data Interface removes

    This capability is why an in-process analytical stack can combine tools that were developed independently without incurring a translation tax between them. A query engine can execute a filter, hand the resulting Arrow columns to a dataframe library for a transformation, and pass those to a machine-learning framework, all without any of the three re-serializing the data. The comparison of in-process engines that read Parquet and exchange Arrow buffers is examined in the companion post on DuckDB versus Polars for in-process analytics.

    Three ways Arrow data moves

    Arrow data is exchanged through several mechanisms, and distinguishing them prevents confusion. The C Data Interface moves data within a single process by passing pointers to buffers already in memory. Arrow IPC, also known as the Feather file format, serializes Arrow data into a byte stream for transmission across a network or for writing to a file — it is a wire and storage format for Arrow itself. Parquet, finally, is the durable at-rest format optimized for long-term storage rather than for immediate computation. Each serves a different boundary: within a process, across a process or network, and at rest. The figure below maps the three mechanisms to the boundaries they cross.

    Where each Arrow-related mechanism operates Within one process — Arrow C Data Interface Library A pointer toshared buffer Library B zero-copy — no serialization Across processes or a network — Arrow IPC / Feather Process A serializedbyte stream Process B serialize then deserialize At rest on disk or object store — Parquet Writer encoded + compressedParquet file Reader (later) durable, space-efficient, decoded back into Arrow on read

    Mechanism Boundary it crosses Copy? Optimized for
    Arrow C Data Interface Library to library, same process No (zero-copy) In-memory computation handoff
    Arrow IPC / Feather Process to process, network, file Serialize / deserialize Transmitting Arrow data
    Parquet At rest on disk / object store Encode + compress Durable, space-efficient storage

     

    Table formats such as Apache Iceberg, Delta Lake, and Apache Hudi layer a metadata and transaction management system on top of Parquet data files rather than replacing them; the distinction between the file layer and the table layer is examined in the comparison of Iceberg, Delta Lake, and Hudi table formats. A transformation tool that materializes analytical models frequently writes those models as Parquet, a pattern covered in the guide to dbt transformation pipelines.

    Practical guidance for tuning

    The internals described above translate into a small set of tuning decisions. None of them has a single correct answer; each depends on the storage medium, the reader’s memory budget, and the query patterns.

    Row-group sizing

    The default row-group size is 128 MB (parquet.apache.org configurations, as of 2026-07-02). A larger row group amortizes footer and metadata overhead across more rows and produces longer contiguous reads, which suits object storage where each request has latency. A smaller row group increases the granularity of row-group statistics pruning and reduces the memory a reader must hold to process one row group. When files are read from object storage, aligning the row-group size with the object store’s block or multipart size reduces the number of range requests. When a reader has a constrained memory budget, a smaller row group prevents it from having to buffer an entire 128 MB band per column.

    Sorting to help encoding and pruning

    Sorting the data by a frequently filtered column improves two mechanisms at once. First, a sorted numeric column has small, repetitive deltas, which makes DELTA_BINARY_PACKED encoding highly effective. Second, sorting tightens the minimum and maximum ranges within each row group and page, so that row-group statistics and the Page Index prune far more aggressively — an unsorted column tends to span the full value range in every row group, defeating range-based pruning. Sorting therefore benefits both file size and scan speed.

    Tip: Sort a table by the column that queries filter on most often before writing it to Parquet. The min/max statistics in each row group and page then form tight, non-overlapping ranges, which lets predicate pushdown skip most of the file. This single choice often affects scan performance more than the compression codec does.

    When to enable Bloom filters

    A Bloom filter earns its space only on a high-cardinality column that is queried with equality predicates, such as a user identifier, a session token, or an email address. On such a column, minimum and maximum ranges overlap across every row group and cannot prune, so the Split Block Bloom Filter provides the only membership-based pruning available. On a low-cardinality column, a Bloom filter is wasteful, because dictionary encoding and range statistics already prune those columns effectively. Bloom filters should be enabled selectively, per column, based on query patterns.

    Choosing a codec and page version

    For hot data queried frequently, Snappy’s fast decompression favors query latency. For cold or archival data, Gzip’s higher ratio reduces storage cost at the expense of slower reads. Zstd offers a tunable middle ground and is increasingly chosen as a default because its ratio approaches Gzip’s at speeds closer to Snappy’s; the exact ratio remains workload-dependent and should be measured on the real dataset. DataPageV2 is required to use the delta encoding family, so writers that store sorted integer or timestamp columns benefit from enabling V2 pages. These file-level choices are typically set once in the writer configuration of the job that produces the data, whether that job runs under an orchestrator such as the one described in the guide to Apache Airflow pipeline orchestration or as part of an ingestion pipeline like the one in the walkthrough of an InfluxDB-to-Iceberg data pipeline.

    Frequently Asked Questions

    What is the difference between Apache Parquet and Apache Arrow?

    Parquet is a format for storing columnar data on disk or in object storage, optimized for space efficiency through encoding and compression. Arrow is a format for holding columnar data in memory, optimized for fast processing on CPUs and GPUs. A reader decodes Parquet files into Arrow arrays to compute on them. The two are designed together: Parquet handles data at rest and Arrow handles data in memory.

    What is the default row-group and page size in Parquet?

    The default row-group size is 128 MB, set by the parquet.block.size configuration at 134217728 bytes, and the default page size is 1 MB, set by parquet.page.size at 1048576 bytes (parquet.apache.org configurations, as of 2026-07-02). A row group is a horizontal band of rows processed independently, and a page is the smallest unit that must be read and decompressed in full to access any record inside it.

    How does predicate pushdown skip data in a Parquet file?

    Predicate pushdown prunes data in three tiers. Row-group statistics in the footer store per-column min, max, and null count, letting a reader skip an entire row group. The Page Index refines this to individual pages using per-page min and max values with a row-index-to-offset map. The Split Block Bloom Filter handles equality on high-cardinality columns where ranges cannot prune, answering “definitely not present” or “probably present” (apache/parquet-format BloomFilter.md, as of 2026-07-02).

    Why do Parquet files need both encoding and compression?

    Encoding and compression are two separate layers that stack. Encoding is a structural, format-aware transformation of one column’s values, such as dictionary encoding or delta encoding, that exploits the column’s type and distribution. Compression is a general-purpose byte-level codec, such as Snappy or Zstd, applied to the already-encoded page. Encoding often rearranges bytes so the compressor finds more redundancy; BYTE_STREAM_SPLIT is a clear example, adding no size reduction itself but improving downstream compression (parquet.apache.org encodings, as of 2026-07-02).

    What does the Arrow C Data Interface do?

    The C Data Interface lets separate libraries in the same process share Arrow column buffers without copying, by passing pointers through a small set of standard C structures. Its only requirement is a stable C application binary interface (arrow.apache.org CDataInterface.html, as of 2026-07-02). This lets a query engine, a dataframe library, and a machine-learning framework exchange columns without serializing and deserializing the data at each handoff.

    References

    Conclusion

    A Parquet file is not an opaque blob but a carefully layered structure whose every level serves a purpose. The file-to-row-group-to-column-chunk-to-page hierarchy makes projection pushdown free and gives predicate pushdown its units of pruning. The encoding layer exploits the type and distribution of each column, and the separate compression layer removes the byte-level redundancy that remains. The footer’s statistics, the Page Index, and the Split Block Bloom Filter combine into a three-tier funnel that lets a reader avoid the majority of the data a query does not need. Apache Arrow completes the picture by defining the in-memory layout into which those pages decode, and its C Data Interface lets independent libraries share columns without paying a serialization cost.

    These internals are not academic. Row-group sizing, sorting by a filtered column, selective Bloom filters, DataPageV2 for delta encodings, and codec choice are all decisions that follow directly from understanding the mechanism. An engineer who knows what the bytes look like can reason about why a scan is slow and what to change, rather than treating the format as a black box. The formats continue to evolve — Parquet at specification version 2.12.0 with a new Variant type announced in February 2026, and Arrow at release 24.0.0 from April 2026 (as of 2026-07-02) — but the columnar principles described here remain the stable foundation beneath every analytical query that touches them.

  • DuckDB vs Polars for In-Process Analytics: A Practical Comparison

    DuckDB and Polars have become two of the most widely adopted tools for analytical work that runs inside a single application process, rather than against a separate database server or a distributed cluster. Both read columnar files such as Parquet directly, both execute queries over an in-memory layout derived from the Apache Arrow format, and both are fast enough that many data teams now reach for one of them before considering a cluster engine such as Apache Spark. They are, however, built around different ideas: DuckDB is a relational database that speaks SQL, while Polars is a DataFrame engine driven by an expression API and written in Rust. This post compares the two as engineering tools — their execution models, their memory behavior under load, the benchmark evidence that exists (and its limits), and the often-overlooked fact that they can hand data to each other with no copy. The goal is not to crown a single winner, because the two trade the performance lead depending on scale and workload, but to give a data engineer a clear basis for choosing one, the other, or both.

    Summary

    What this post covers: A practical, decision-oriented comparison of DuckDB and Polars as in-process analytical engines — their execution models, memory behavior, the public benchmark evidence and its caveats, and how the two interoperate over Apache Arrow.

    Key insights:

    • Both tools run inside a single application process and read Parquet and Arrow data directly, which makes them an alternative to a database server or a Spark cluster rather than a competitor to each other in every case.
    • DuckDB presents a SQL interface backed by a columnar-vectorized engine with ACID transactions and morsel-driven parallelism, while Polars presents a DataFrame expression API with explicit eager and lazy evaluation and a query optimizer.
    • The two leading public benchmarks disagree on the leader by scale and metric, and both carry important caveats: the Polars PDS-H results are vendor-published and are not comparable to certified TPC-H figures, while the memory stress test is from a consultancy blog.
    • Default memory behavior differs sharply in the Parquet stress test, where DuckDB held roughly 1.3 GB against default Polars at roughly 17 GB on a 140 GB file, though Polars closed that gap when async reads were forced.
    • DuckDB and Polars exchange data zero-copy over Arrow, so a DuckDB query can read a Polars DataFrame by variable name and return a result as a Polars frame, making “use both” a legitimate design.

    Main topics: Two engines one process, Architecture compared, Performance and memory, Interoperability over Arrow, Choosing the right engine.

    Two engines, one process: what “in-process analytics” means

    The term in-process describes software that runs within the address space of the calling application rather than as a separate service that the application contacts over a network or a socket. A traditional analytical database such as a data warehouse runs as a server: an application opens a connection, sends SQL over the wire, and receives rows back. An in-process engine instead loads as a library inside the program — a Python interpreter, for example — and operates on data in the same memory the program already holds. There is no server to start, no port to manage, and no connection pool to tune.

    DuckDB describes itself as “an in-process analytical (OLAP) database.” OLAP, or online analytical processing, refers to read-heavy workloads that scan and aggregate large numbers of rows — the opposite of online transaction processing (OLTP), which favors many small, indexed reads and writes. Polars describes itself as “an analytical query engine written for DataFrames.” A DataFrame is a two-dimensional table abstraction, familiar from libraries such as pandas, in which columns carry typed values and operations are expressed as transformations of whole columns. The two descriptions point at the same workload — analytical queries over tabular data — approached from a relational direction (DuckDB) and a DataFrame direction (Polars). (Sources: duckdb.org/why_duckdb; pypi.org/project/polars, as of 2026-06-22.)

    The distinction from a server architecture matters for how a system is built and operated. The figure below contrasts the two deployment shapes.

    Client-server database vs in-process engine Client-server (warehouse / RDBMS) Application (Python / app) SQL over network DB server (separate) Disk / storage Two processes, network hop, rows serialized over the wire In-process (DuckDB / Polars) Application process App code (Python) Engine (library) shared memory, no copy across the wire Parquet / Arrow files (local or object store) One process, files read directly

    Neither engine is a distributed system, and this is the single most useful framing for someone deciding when to use them. Apache Spark, the dominant cluster engine for large-scale processing, distributes work across many machines coordinated by a driver and scheduler. That model is appropriate when a dataset genuinely does not fit on one machine or when a workload must be elastic across a fleet. DuckDB and Polars instead exploit a single machine well — every core, the full memory hierarchy, and fast local or cloud storage — and increasingly that is sufficient. Cloud instances now offer hundreds of gigabytes of RAM, and many analytical datasets are smaller than teams assume. When a job fits comfortably on one node, an in-process engine removes the operational weight of a cluster while often finishing faster, because no time is lost to network shuffles or task scheduling.

    Key Takeaway: DuckDB and Polars occupy the same layer of the data stack — an in-process compute engine that reads columnar files directly. The first decision is not “DuckDB or Polars” but “single-node engine or cluster,” and for a large share of analytical workloads a single-node engine is now the better default.

    Because both engines sit at the compute layer, they read from the storage and table-format layer below them rather than replacing it. A team standardizing on open table formats may want to review how those formats differ in the comparison of Apache Iceberg, Delta Lake, and Hudi table formats, since DuckDB and Polars are the engines that would read tables stored in those formats.

    Architecture compared: SQL versus DataFrame, eager versus lazy

    The two engines share a foundation and diverge above it. Both are columnar, meaning they store and process data column by column rather than row by row. Column-oriented layout suits analytical queries because such queries typically touch a few columns across many rows; reading only the needed columns reduces input/output and lets the processor work on long, uniform runs of values. Both are also vectorized: instead of evaluating one value at a time, they process a batch of values — a vector — in each step of execution, which keeps the processor’s pipeline full and amortizes the overhead of interpreting each operation. DuckDB states that in its engine “queries are still interpreted, but a large batch of values (a ‘vector’) are processed in one operation.” Polars lists “SIMD” operations, referring to Single Instruction, Multiple Data, a processor capability that applies one instruction to several data elements at once. (Sources: duckdb.org/why_duckdb; pypi.org/project/polars, as of 2026-06-22.)

    SQL engine versus DataFrame engine

    The most visible difference is the interface. DuckDB executes SQL, the declarative query language used by relational databases for five decades. A user writes what result is wanted and the engine decides how to produce it. DuckDB also provides “ACID guarantees through our custom, bulk-optimized Multi-Version Concurrency Control (MVCC)” — ACID being the set of transaction properties (atomicity, consistency, isolation, durability) that keep concurrent changes correct, and MVCC being a technique that lets readers and writers proceed without blocking each other by keeping multiple versions of a row. (Source: duckdb.org/why_duckdb, as of 2026-06-22.)

    Polars exposes “a DataFrame expression API plus a SQL interface.” Its primary interface is the expression API, in which transformations are composed as method calls and column expressions in the host language. Polars is “written in Rust,” uses the “Apache Arrow Columnar Format,” and offers frontends in “Python, Rust, NodeJS, R, [and] SQL.” A DataFrame engine appeals to programmers who prefer composing transformations as code, with the full control flow and tooling of a general-purpose language, over embedding SQL strings. (Source: pypi.org/project/polars, as of 2026-06-22.)

    Execution models compared DuckDB SQL query (declarative) Query optimizer Vectorized pipeline (batch of values per step) Morsel-driven parallelism (cores share work units) Polars Expression API eager call or lazy plan Query optimizer (lazy) Multi-threaded + SIMD (one instruction, many values) In-memory or streaming collect(engine=’streaming’) Shared substrate: columnar, vectorized, Apache Arrow-style memory Apache Arrow columnar memory + Parquet on disk

    Parallelism is realized differently. DuckDB uses “morsel-driven parallelism,” a scheduling approach in which input data is divided into small, fixed-size chunks called morsels that worker threads pull and process, so that load balances across cores without a rigid up-front partitioning. Polars lists “multi-threaded” execution combined with SIMD. Both aim at the same outcome — saturating all available cores on one machine — through different scheduling strategies. (Sources: duckdb.org/why_duckdb; pypi.org/project/polars, as of 2026-06-22.)

    Eager versus lazy evaluation

    Polars makes an explicit distinction that shapes how performant code is written: eager versus lazy evaluation. In eager mode, each operation runs immediately and returns a materialized result, which is convenient for interactive exploration. In lazy mode, operations build a query plan that is not executed until a terminal call — collect() — is reached. Deferring execution lets the optimizer rewrite the whole plan before any work happens: it can push filters down to the data source so that fewer rows are read, prune unused columns, and reorder operations. Polars documents both modes as “Lazy | Eager execution” with “Query optimization.” (Source: pypi.org/project/polars, as of 2026-06-22.)

    Eager vs lazy evaluation in Polars Eager scan + run filter + run groupby + run result each step materializes immediately — no whole-plan view Lazy scan (plan) filter (plan) groupby (plan) optimizer rewrites pushdown + pruning collect() execute once work is deferred until collect(); optimizer sees the full plan first DuckDB’s SQL is likewise declarative — the optimizer always sees the whole statement

    DuckDB does not expose an eager/lazy switch because SQL is declarative by nature: a complete statement is handed to the optimizer, which always sees the whole query before execution. The lazy mode of Polars is, in effect, a way to recover that whole-query view inside an imperative DataFrame API. For a data engineer, the practical guidance is that Polars code intended for production should use the lazy API, since eager chains forgo the optimizations that make the engine fast on large inputs.

    Streaming and larger-than-memory execution

    A defining concern for single-node engines is what happens when a dataset is larger than available RAM. Polars supports processing data “in a streaming fashion,” enabled with collect(engine='streaming'), which executes the query in chunks so that the full dataset need not be resident at once. DuckDB, as a database, has long been designed to spill intermediate state to disk when memory is exhausted, allowing queries to complete on data larger than memory. Both engines therefore offer a path beyond the RAM ceiling, though, as the benchmark section shows, their default memory footprints under stress can differ substantially. (Sources: pypi.org/project/polars; duckdb.org/why_duckdb, as of 2026-06-22.)

    Streaming a dataset larger than RAM On disk (140 GB) chunk 1 chunk 2 chunk 3 chunk N … one chunk at a time Engine process chunk + update aggregates RAM holds: 1 chunk + running state only Final result after last chunk Peak memory stays low because the full dataset is never resident at once

    Both engines build on the Apache Arrow columnar memory format, an open standard for representing tabular data in memory so that different tools can share buffers without reformatting. Arrow is the reason the two engines interoperate so cheaply, a point developed in the interoperability section. The same Parquet files — Parquet being the dominant open columnar file format on disk — can be read directly by either engine, which means a team is not locked into a proprietary storage format by either choice.

    Performance and memory: what the benchmarks actually show

    Performance comparisons between DuckDB and Polars are widely circulated and widely misread. Two public benchmarks are credible enough to discuss, and both must be read with their conditions in mind. The first is a throughput benchmark published by the Polars project; the second is a memory-focused stress test from an independent consultancy. No vendor-neutral, audited TPC-H comparison of the two engines existed as of writing, so these are the most credible public figures available, and neither should be treated as the final word.

    The PDS-H throughput benchmark

    The Polars project publishes a benchmark based on PDS-H, a workload derived from TPC-H. TPC-H is a long-standing industry decision-support benchmark consisting of analytical queries over a synthetic dataset whose size is set by a “scale factor” (SF); SF-10 is roughly ten gigabytes of raw data and SF-100 roughly one hundred. The PDS-H total execution times reported by the Polars project (May 2025) are shown below.

    Engine / mode SF-10 total time SF-100 total time
    Polars (streaming) 3.89 s 23.94 s
    DuckDB 5.87 s 19.65 s
    Polars (in-memory) 9.68 s 152.27 s
    Dask 46.02 s 548.52 s
    PySpark 120.11 s 312.43 s
    pandas 365.71 s

     

    Two patterns stand out. First, both DuckDB and Polars are an order of magnitude faster than pandas, PySpark, and Dask across these scales, which supports the broader claim that an in-process columnar engine outperforms both a single-threaded DataFrame library and a cluster engine on a single node at these sizes. Second, the lead changes with scale: Polars streaming was fastest at SF-10 (3.89 s versus DuckDB’s 5.87 s), while DuckDB was fastest at SF-100 (19.65 s versus Polars streaming’s 23.94 s). The collapse of Polars in-memory from 9.68 s at SF-10 to 152.27 s at SF-100 also shows why the streaming engine matters as data grows. (Source: pola.rs/posts/benchmarks, as of 2026-06-22.)

    PDS-H total execution time (lower is better) SF-10 (~10 GB) 3.89 Pol str 5.87 DuckDB 9.68 Pol mem 46.0 Dask 120 Spark 366 pandas SF-100 (~100 GB) 19.7 DuckDB 23.9 Pol str 152 Pol mem 312 Spark 549 Dask Seconds, total across the query suite. Bars not to a single shared scale across panels. Caveat: Polars-published PDS-H, May 2025. PDS-H is derived from TPC-H but its rules are modified; results are NOT comparable to certified TPC-H figures. Treat as directional, not authoritative.

    Caution: The PDS-H numbers are published by the Polars project, and PDS-H “results are not comparable to published TPC-H Benchmark results” because the rules are modified to accommodate both SQL and DataFrame APIs. A benchmark maintained by one of the engines under test should be read as directional evidence about magnitude and scaling behavior, not as a neutral ranking. (Source: pola.rs/posts/benchmarks, as of 2026-06-22.)

    The Parquet memory stress test

    Throughput is only one axis; peak memory determines whether a job fits on a given machine at all. An independent benchmark published on the codecentric blog (published 2026-01-20, updated 2026-02-02) scaled a single Parquet file from roughly 2 GB to 140 GB and measured both execution time and peak memory. On execution time the two engines were very similar across the range, with DuckDB roughly one second faster at the largest scale. The more revealing result was peak memory at 140 GB, summarized below.

    Configuration (140 GB single file) Peak memory Notes
    DuckDB ~1.3 GB conservative by default
    Polars (default) ~17 GB default read path
    Polars (forced async) ~750 MB async reads enabled

     

    Two findings deserve emphasis. First, default behavior diverged by more than an order of magnitude: DuckDB held about 1.3 GB while default Polars used about 17 GB on the same 140 GB file. Second, the gap was not intrinsic — forcing asynchronous reads brought Polars down to about 750 MB, below DuckDB’s footprint. Separately, partitioning the 140 GB dataset into 72 smaller files cut DuckDB’s memory by roughly eight times and default Polars’ by roughly four times, showing that file layout strongly influences memory regardless of engine. (Source: codecentric.de blog, as of 2026-06-22.)

    Peak memory on a 140 GB Parquet file (lower is better) ~1.3 GB DuckDB ~17 GB Polars (default) ~750 MB Polars (forced async) Partitioning the 140 GB file into 72 files cut DuckDB memory ~8x and default Polars ~4x. Source: codecentric blog (consultancy), published 2026-01-20, updated 2026-02-02. Memory stress-test conditions.

    The combined reading of the two benchmarks is consistent with the framing that neither engine is universally faster. On throughput the lead trades by scale; on memory DuckDB is more conservative out of the box, but Polars can match or beat it once tuned, and file layout dominates both. For a team selecting between data stores and engines more broadly, the same “measure under your own conditions” discipline applies; the comparison of databases for preprocessed time-series data walks through a similar evaluation for a related class of workload.

    Interoperability: zero-copy handoff over Apache Arrow

    A point often missing from “DuckDB versus Polars” discussions is that the two are not mutually exclusive within one program. Because both represent data in the Apache Arrow columnar format, they can share the same in-memory buffers without copying. The official DuckDB documentation states that “DuckDB can read Polars DataFrames and convert query results to Polars DataFrames. It does this internally using the efficient Apache Arrow integration.” In practice, a Polars DataFrame held in a variable can be referenced directly by name inside a SQL query — duckdb.sql("SELECT * FROM df") — and the result can be converted back to a Polars DataFrame with .pl(), or to a Polars LazyFrame with .pl(lazy=True). The pyarrow package is required for this path. (Source: duckdb.org/docs/current/guides/python/polars.html, as of 2026-06-22.)

    Zero-copy means the handoff transfers ownership of, or a reference to, the existing memory buffer rather than serializing and reallocating the data. The figure below shows a single Arrow buffer being read by both a Polars DataFrame and a DuckDB query.

    Zero-copy handoff over Apache Arrow One Arrow buffer columnar memory (no duplicate copy) Polars DataFrame variable: df reference DuckDB query reads df by name reference result = duckdb.sql(“SELECT * FROM df”) # df is a Polars frame out = result.pl() # back to Polars DataFrame # requires pyarrow; no serialization between the two engines

    This interoperability changes the decision calculus. A pipeline can perform DataFrame-style feature preparation in Polars, hand the frame to DuckDB for a complex multi-table SQL join and aggregation, and receive the result back as a Polars frame for the next step — all within one process and without paying a serialization cost at each boundary. The two engines become complementary stages rather than competing choices.

    Both engines also fit cleanly into the wider data ecosystem. They read Parquet from local disk and from object storage such as Amazon S3, which is the common substrate for analytical data lakes. DuckDB has an adapter, dbt-duckdb, that lets it serve as the execution engine for transformation models; teams already using that framework can read about the model-based workflow in the guide to building transformation pipelines with dbt. An in-process engine is also a natural transform step inside a scheduled workflow, where an orchestrator triggers the job; the patterns for that are covered in the guide to orchestrating data pipelines with Apache Airflow. When the analytical layer is fed by streaming ingestion, the upstream side is described in the guide to change data capture with Debezium and Kafka.

    Tip: Before treating the choice as exclusive, consider whether a pipeline benefits from both. Polars is convenient for expressive, programmatic column transformations, while DuckDB is convenient for relational joins and aggregations expressed in SQL. The zero-copy Arrow bridge means the two can be combined with little overhead.

    Choosing: when DuckDB, when Polars, when both

    Because the engines trade the performance lead by scale and workload, the most reliable basis for choosing is fit — to the team’s skills, the surrounding system, and the shape of the work — rather than a single benchmark number. The decision flow below summarizes the practical signals.

    Which engine for the job? Start: analytical workload Primary interface: SQL or DataFrame? SQL DuckDB DataFrame Polars also if: existing SQL / DB skills tight memory by default ACID / transactions dbt-duckdb workflow also if: DataFrame-native code ML feature preprocessing Rust integration expressive lazy plans Both styles in one pipeline? Use both, bridged zero-copy over Arrow Neither is universally faster; choose by interface, memory behavior, and ecosystem fit. For data far beyond one large machine, reconsider a distributed engine instead.

    The table below condenses the same guidance into a side-by-side reference.

    Dimension DuckDB Polars
    Primary interface SQL (plus DataFrame relations) DataFrame expression API (plus SQL)
    Implementation C++, no external dependencies Rust
    Evaluation Declarative SQL (whole-query optimization) Eager or lazy; lazy enables optimization
    Parallelism Morsel-driven Multi-threaded + SIMD
    Transactions ACID via MVCC Not a transactional store
    Default memory (stress test) Conservative (~1.3 GB at 140 GB) Higher by default (~17 GB), tunable to ~750 MB
    Larger-than-memory Spills to disk Streaming engine
    Strong fit Relational joins/aggregations, SQL teams, dbt Programmatic transforms, ML preprocessing, Rust apps

     

    A reasonable default is to choose by interface and team skills first: a team comfortable in SQL, or one that wants transactional guarantees and conservative memory out of the box, will find DuckDB the lower-friction option, while a team writing DataFrame-style transformation code, preparing features for machine learning, or working in Rust will find Polars more natural. When a single pipeline genuinely contains both shapes of work, the zero-copy Arrow bridge makes “use both” a sound engineering choice rather than a compromise. The one case where neither is the right answer is data that cannot be made to fit on a single large machine even with streaming or spilling; that workload still belongs on a distributed engine.

    Frequently Asked Questions

    Is DuckDB or Polars faster?

    Neither is faster in all cases. In the Polars-published PDS-H benchmark (May 2025), Polars streaming was fastest at scale factor 10 (3.89 s versus DuckDB’s 5.87 s) while DuckDB was fastest at scale factor 100 (19.65 s versus 23.94 s). In the independent codecentric Parquet stress test, execution times were very similar across scales, with DuckDB about one second faster at 140 GB. The lead trades by scale and workload, and both benchmarks carry caveats: the PDS-H figures are vendor-published and not comparable to certified TPC-H results, and the stress test is from a consultancy blog. (Sources: pola.rs/posts/benchmarks; codecentric.de blog, as of 2026-06-22.)

    Can DuckDB and Polars be used together?

    Yes. Both represent data in the Apache Arrow columnar format, so they exchange data zero-copy. A Polars DataFrame in scope can be queried directly by variable name in SQL, for example duckdb.sql("SELECT * FROM df"), and a DuckDB result converts back to a Polars DataFrame with .pl() or to a LazyFrame with .pl(lazy=True). The pyarrow package is required. (Source: duckdb.org/docs/current/guides/python/polars.html, as of 2026-06-22.)

    What does “in-process” mean, and how is it different from Spark?

    In-process means the engine runs as a library inside the application’s own process rather than as a separate server. DuckDB and Polars both run on a single machine and read files directly, so they remove the operational overhead of a server or a cluster. Apache Spark is a distributed engine that coordinates work across many machines; it is appropriate when data does not fit on one node or when elasticity across a fleet is required. For workloads that fit on one machine, an in-process engine is often simpler and faster.

    Why does Polars distinguish eager and lazy evaluation?

    In eager mode each operation runs immediately, which suits interactive exploration. In lazy mode operations build a query plan that is executed only at a collect() call, which lets the optimizer rewrite the whole plan first — pushing filters down, pruning unused columns, and reordering steps. Production Polars code generally uses the lazy API to gain these optimizations. DuckDB has no equivalent switch because SQL is declarative and the optimizer always sees the complete query. (Source: pypi.org/project/polars, as of 2026-06-22.)

    Which uses less memory?

    In the codecentric stress test on a 140 GB single Parquet file, DuckDB used about 1.3 GB by default while default Polars used about 17 GB, but forcing asynchronous reads brought Polars down to about 750 MB. Partitioning the dataset into 72 files cut DuckDB’s memory roughly eightfold and default Polars’ roughly fourfold. DuckDB is more conservative out of the box, but Polars is tunable, and file layout influences memory strongly for both. (Source: codecentric.de blog, as of 2026-06-22.)

    References

    Conclusion

    DuckDB and Polars represent two routes to the same destination: fast analytical computation inside a single process, over open columnar data, on one machine. DuckDB approaches the problem as a relational database — SQL, ACID transactions through MVCC, conservative default memory, and morsel-driven parallelism — while Polars approaches it as a Rust DataFrame engine with an expressive expression API, explicit lazy evaluation, and SIMD-accelerated multi-threading. The public benchmarks confirm that neither holds a universal performance lead; the Polars-published PDS-H figures show the lead changing between scale factor 10 and 100, and the independent codecentric stress test shows DuckDB conservative on memory by default while Polars matches it once tuned, with file layout dominating both. Because both build on Apache Arrow, the most consequential and least discussed fact is that they interoperate zero-copy, which turns an apparent rivalry into a pairing. A sound selection therefore rests on interface preference, memory behavior, and ecosystem fit rather than on a single throughput number — and where a pipeline contains both relational and DataFrame work, the right answer is often to use both, bridged over Arrow.

  • Apache Iceberg vs Delta Lake vs Hudi: Choosing a Table Format

    Open table formats turned commodity object storage into a transactional database layer, and the choice among the three principal implementations — Apache Iceberg, Delta Lake, and Apache Hudi — is one of the foundational decisions in a modern lakehouse. The decision is consequential because the table format governs how data is written, updated, queried, and shared across every engine that touches the storage layer. It is also a decision that has changed character over the past two years. Where engineers once treated the choice as a long-term lock-in, the three formats have begun to converge toward interoperability, so that the question is increasingly about defaults and operational fit rather than permanent commitment.

    This article examines what a table format is and why it became necessary, how each of the three formats is designed internally, how they compare across the dimensions that matter in practice, and why the so-called format war is giving way to a model in which one physical dataset can be read as more than one format. It closes with concrete guidance on how to choose in 2026.

    Summary

    What this post covers: This post compares Apache Iceberg, Delta Lake, and Apache Hudi as lakehouse table formats — their internal architecture, multi-engine reach, and the convergence that is reshaping the selection decision — and offers practical guidance for choosing one in 2026.

    Key insights:

    • A table format adds an atomicity, schema-evolution, and time-travel layer on top of plain Parquet files, replacing the fragile “directory of files” model with a transactional metadata layer.
    • Iceberg, Delta Lake, and Hudi differ most in their metadata models and their design centers of gravity: Iceberg favours engine-neutral interoperability, Delta Lake favours deep Spark and Databricks integration, and Hudi favours high-frequency streaming upserts.
    • Apache Iceberg has become the de-facto industry standard in 2026, adopted by every major cloud provider and query engine, owing to its vendor-neutral governance, partition evolution, and broad engine support.
    • The format war is ending: Databricks acquired Tabular for more than one billion dollars in 2024, Delta UniForm exposes Delta tables as Iceberg, Hudi can output Iceberg metadata, and Apache XTable translates metadata omni-directionally with no data copying.
    • For most new, multi-engine deployments Iceberg is the safe default; Delta Lake fits Databricks-centric stacks; Hudi fits upsert-heavy streaming ingestion; and XTable interoperability can serve consumers that expect different formats from one dataset.

    Main topics: Why Table Formats Exist, Apache Iceberg, Delta Lake, Apache Hudi, A Head-to-Head Comparison, The Convergence Story, How to Choose in 2026.

    Why Table Formats Exist

    A data lake in its simplest form is a collection of files — most commonly Apache Parquet, a columnar file format — stored in an object store such as Amazon S3, Google Cloud Storage, or Azure Blob Storage. A query engine reads those files and treats them collectively as a table. This arrangement is inexpensive and scalable, but the abstraction is weak. The storage layer knows only about files and directories; it has no concept of a table, a transaction, or a consistent snapshot. The problems that follow from this gap are what gave rise to table formats.

    A table format is a specification and metadata layer that sits between a physical collection of data files and the query engines that read them, presenting that collection as a single logical table with database-like guarantees. The most important of those guarantees is ACID — atomicity, consistency, isolation, and durability — the set of properties that ensure a group of changes either fully applies or does not apply at all, that concurrent readers and writers do not observe partial state, and that committed data survives failures. Without a table format, the lake cannot offer these properties.

    Plain Parquet Lake vs. Table-Format Lake Directory of files s3://lake/sales/part-0001.parquet s3://lake/sales/part-0002.parquet s3://lake/sales/part-0003.parquet No atomic commit No safe schema evolution No time travel Readers may see partial writes Listing the directory = the “table” Files + metadata layer Metadata layer (snapshots, schema) part-0001.parquet part-0002.parquet part-0003.parquet Atomic commits (ACID) Schema evolution tracked Time travel to prior snapshots Readers see consistent snapshots

    Consider what happens when a job appends a new day of data to a plain Parquet directory and fails halfway. Some files are written and some are not, and a reader that lists the directory during the failure observes an inconsistent table. The same fragility affects updates and deletes: changing a single record requires rewriting whole files, and there is no transactional boundary to make the change appear atomically. This is the “directory of files” problem — the table is defined by whatever files happen to be present when the engine lists the path, with no authoritative record of which files belong to a committed version.

    Three further capabilities are absent from the plain-Parquet model and are central to why table formats were created. Schema evolution is the ability to add, rename, drop, or reorder columns without rewriting historical data or breaking existing readers. Time travel is the ability to query the table as it existed at a previous point in time, which supports reproducibility, auditing, and rollback. And efficient query planning depends on metadata that records which files contain which ranges of values, so the engine can skip files that cannot contain matching rows.

    Table formats deliver these capabilities through a metadata layer — a set of files that record the table’s schema, its partitioning, and, critically, the exact list of data files that constitute each committed version of the table. A central concept in two of the three formats is the manifest: a metadata file that enumerates a group of data files together with statistics about their contents, such as the minimum and maximum value of each column within each file. With this information the engine performs file pruning, reading only the files that could contain rows matching a query predicate. The three formats examined below implement these ideas differently, and those differences drive their respective strengths.

    Key Takeaway: A table format is the layer that turns a passive directory of Parquet files into a transactional table with atomic commits, schema evolution, and time travel. The differences among Iceberg, Delta Lake, and Hudi are largely differences in how that metadata layer is structured and what workloads it was optimized for.

    Apache Iceberg: Hierarchical Metadata and Engine Neutrality

    Apache Iceberg was created to provide a table format that no single engine owns and that scales to very large tables without the planning bottlenecks of earlier approaches. Its defining characteristic is a hierarchical metadata architecture and a specification-first design philosophy. The format is defined by a written specification, and any engine that implements the specification can read and write the tables, which is the basis for Iceberg’s wide adoption.

    The metadata hierarchy

    An Iceberg table is described by a tree of metadata files. At the root is a metadata.json file (often called the table metadata file) that records the current schema, partition specification, and a pointer to the current snapshot. Each snapshot references a manifest list, a file that enumerates the manifest files belonging to that snapshot. Each manifest file, in turn, tracks a set of individual data files and stores column-level statistics for them, such as per-column lower and upper bounds and null counts (Apache Iceberg documentation; Dremio, as of 2026).

    Iceberg Metadata Hierarchy metadata.json schema, partition spec, current snapshot Manifest list enumerates manifests in this snapshot Manifest file A + column min/max/null stats Manifest file B + column min/max/null stats Manifest file C + column min/max/null stats data files (.parquet) data files (.parquet) data files (.parquet) Column statistics live in the manifests, so the engine prunes files during planning without reading the data itself.

    This hierarchy has two practical consequences. First, query planning is efficient even for tables with millions of files, because the engine reads the manifest list and the relevant manifests rather than listing the object store. Second, because the statistics are recorded in the manifests, the engine can prune files during planning — eliminating files whose recorded value ranges cannot satisfy the query predicate — without opening the data files. This is the same principle that motivates careful storage selection for analytical workloads, a topic explored in the discussion of choosing databases for preprocessed time-series data.

    Partition evolution and hidden partitioning

    Two features distinguish Iceberg’s treatment of partitioning. Hidden partitioning means the table records how a column is transformed into partition values — for example, by truncating a timestamp to its day — so that queries filtering on the raw column automatically benefit from partition pruning without the writer or reader having to reference a separate partition column. Partition evolution means the partitioning scheme of a table can be changed over time without rewriting the historical data; new data is written under the new scheme while old data remains valid under the old one. This is significant operationally, because it removes one of the most expensive and disruptive migrations in traditional partitioned tables.

    Engine reach

    Iceberg’s specification-first, engine-agnostic design has produced the broadest multi-engine support of the three formats. As of 2026 it is read and written by Apache Spark, Apache Flink, Trino, and DuckDB, and is supported as a table format by managed warehouses including Snowflake and Google BigQuery (RisingWave; Dremio; Onehouse comparison guides, as of 2026). This breadth is the principal reason Iceberg has become the common denominator across the industry, a point developed in the convergence section. A practical example of landing data into Iceberg from an operational source is described in the guide to building an InfluxDB-to-Iceberg data pipeline with Telegraf, and stream processors such as Flink commonly write into Iceberg as part of complex event processing pipelines.

    Delta Lake: A Transaction Log Born at Databricks

    Delta Lake originated at Databricks and was designed first and foremost to bring transactional reliability to data stored for use with Apache Spark. Its integration with Spark is the deepest of the three formats, reflecting that shared origin (Databricks; Dremio, as of 2026). Where Iceberg organizes metadata as a tree of manifest files, Delta Lake organizes it as an ordered transaction log.

    The transaction log

    A Delta table keeps a directory named _delta_log alongside its data files. Each successful commit writes a new JSON file to this directory, numbered sequentially, recording the actions that the commit performed — which data files were added and which were removed, along with metadata changes. The current state of the table is obtained by replaying these JSON commits in order. To prevent the replay from growing unbounded, Delta Lake periodically writes a checkpoint in Parquet format that captures the full table state up to a given commit. A reader then loads the most recent checkpoint and applies only the JSON commits that follow it, which keeps state reconstruction efficient.

    Delta Lake Transaction Log (_delta_log) 000000.json commit 0 000001.json commit 1 000002.json commit 2 000010 .checkpoint .parquet 000011.json commit 11 000012.json commit 12 Reading current state Load latest checkpoint (commit 10) + replay commits 11, 12 = consistent table snapshot Each JSON commit records + data files added – data files removed schema / metadata changes

    The transaction log model gives Delta Lake straightforward time travel — a reader can request the table state as of any commit version — and reliable concurrency control through optimistic commits against the log. Because the log is the single ordered authority on table state, the semantics map cleanly onto Spark’s execution model, which is part of why Delta Lake remains the most natural choice within Spark-centric and Databricks-centric environments. Transformation workflows that run on top of such tables, for instance with dbt-based transformation pipelines, treat the table as the consistent input and output of each model run.

    UniForm and interoperability

    For most of Delta Lake’s history its log-based metadata was readable only by Delta-aware engines, which limited reach relative to Iceberg. Delta UniForm addresses this directly. UniForm provides interoperability across Delta Lake, Iceberg, and Hudi by generating the metadata that other formats expect alongside the Delta log, and it supports the Iceberg REST catalog interface so that Iceberg clients can discover and read the tables (Databricks, as of 2026). In effect, a table written as Delta can be presented to an Iceberg reader without copying the underlying Parquet data. This is one of the developments that has softened the practical cost of choosing Delta in a mixed-engine environment, and it is examined further in the convergence section.

    Apache Hudi: Built for Streaming Upserts

    Apache Hudi was designed around a workload that the other two formats addressed only later: continuous ingestion of streaming data with frequent record-level updates. The name itself abbreviates “Hadoop Upserts Deletes and Incrementals,” which signals its orientation. As of 2026, Hudi offers the most mature tooling for pure streaming ingestion involving high-frequency upserts (Onehouse; lakeFS, as of 2026). An upsert is an operation that inserts a record if its key does not yet exist and updates the existing record if it does — the natural operation when a change-data-capture stream delivers a steady flow of inserts, updates, and deletes from an operational database. Feeds of this kind commonly arrive through change data capture with Debezium and Kafka or through a Kafka consumer that lands events into the lake.

    Copy-on-Write and Merge-on-Read

    Hudi offers two table types, and the choice between them is the central tuning decision when adopting the format. In a Copy-on-Write (CoW) table, an update rewrites the base file that contains the affected record, so every version of the data is materialized at write time. Reads are therefore fast and simple, because the engine reads finished base files, but writes are more expensive because they rewrite whole files. In a Merge-on-Read (MoR) table, an update is written to a delta log file associated with the base file rather than rewriting the base file immediately; readers merge the base file with its delta logs at query time. Writes are cheaper and lower-latency, which suits high-frequency upserts, at the cost of more work at read time until a background compaction merges the deltas into new base files.

    Hudi: Copy-on-Write vs. Merge-on-Read Copy-on-Write (CoW) base file v1 update arrives base file v2 (rewritten) Write: rewrite whole file (heavier) Read: open finished base file (fast) Best for read-heavy tables with moderate update frequency. Merge-on-Read (MoR) base file updates arrive base file delta log Read: merge base + delta logs merged view at read Write: append delta (light, low latency). Best for high-frequency upserts; compaction merges deltas later.

    Record-level indexing and table management

    To apply an upsert, Hudi must locate the existing record for a given key quickly. It maintains record-level indexing for this purpose — a mapping from record keys to the files that contain them — so an incoming update can be routed to the correct file without scanning the whole table. This indexing is a meaningful part of why Hudi handles high-frequency update workloads efficiently. Hudi also includes built-in table management services: compaction merges MoR delta logs into base files, clustering reorganizes data to improve query locality, and cleaning removes obsolete file versions according to a retention policy. These services can run as part of the ingestion pipeline or as separate jobs, and orchestrating them alongside ingestion is a common use of a workflow scheduler such as Apache Airflow.

    Hudi’s primary ingestion utility is its streaming ingestion tool, historically known as DeltaStreamer (now Hudi Streamer), which reads from sources such as Kafka and applies inserts, updates, and deletes to a Hudi table continuously. This tooling, together with record-level indexing and the MoR table type, is the basis for Hudi’s reputation in upsert-heavy streaming ingestion.

    Native Iceberg output

    Hudi has also moved toward interoperability. Tables created from Hudi version 0.14.0 onwards can be synced to Iceberg and/or Delta Lake through Apache XTable, and Hudi’s native Iceberg support lets a team use Hudi’s managed services — compaction, indexing, and Hudi Streamer ingestion — while outputting Iceberg-compatible tables for downstream consumers (Apache Hudi documentation, as of 2026). This means a pipeline can keep Hudi’s strengths on the write side while presenting Iceberg on the read side, a pattern that the convergence section places in context.

    A Head-to-Head Comparison

    The three formats overlap substantially in their core guarantees — all provide ACID transactions, schema evolution, and time travel — so the meaningful differences lie in their metadata models, their maturity for specific operations, and their ecosystem reach. The matrix below summarizes the breadth of engine support that distinguishes the formats, followed by a feature comparison and a workload mapping.

    Multi-Engine Reach (illustrative breadth) Format Spark Flink Trino Snowflake BigQuery DuckDB Iceberg full full full full full full Delta Lake full partial partial via UniForm via UniForm partial Hudi full full partial via XTable via XTable partial native / full support partial support via interoperability layer Breadth shown is directional, reflecting Iceberg’s role as the common denominator across engines and clouds. Support details evolve by release.

    The grid is directional rather than a precise capability audit; engine support changes with each release, and warehouse vendors continue to add native read paths. The pattern it conveys is the one reported consistently across the comparison literature: Iceberg has the widest native reach, Delta Lake is strongest within Spark and reaches other engines primarily through UniForm, and Hudi is strong in Spark and Flink and reaches warehouses through translation. The detailed feature comparison follows.

    Dimension Apache Iceberg Delta Lake Apache Hudi
    Design origin Engine-neutral, specification-first Databricks; deepest Spark integration Streaming ingestion and upserts
    Metadata model Hierarchy: metadata.json → manifest list → manifests → data files Ordered transaction log (_delta_log) + Parquet checkpoints Timeline + base files and delta logs; record-level index
    Upsert / CDC maturity Supported; improving Strong within Spark Most mature for high-frequency upserts
    Partition evolution Yes, plus hidden partitioning Limited Limited
    Engine support Broadest: Spark, Flink, Trino, Snowflake, BigQuery, DuckDB Spark-first; others via UniForm Spark, Flink; warehouses via translation
    Built-in table management Via engine / catalog services Via engine; optimize and vacuum Built-in compaction, clustering, cleaning
    Governance / catalog Vendor-neutral; Iceberg REST catalog standard Unity Catalog; UniForm exposes Iceberg REST Hive / catalog integrations; XTable sync

     

    The feature comparison clarifies that the formats are not interchangeable on every axis even though their core guarantees overlap. Partition evolution and hidden partitioning are genuine Iceberg differentiators; built-in table management is a genuine Hudi differentiator; and the deepest Spark integration remains a Delta Lake characteristic. The second table maps common workloads to the format that most naturally fits each, before interoperability is taken into account.

    Workload Best-fit format Why
    New multi-engine, vendor-neutral lakehouse Iceberg Broadest engine reach and neutral governance
    Databricks-centric analytics and ML Delta Lake Deepest Spark integration; UniForm for outside reach
    High-frequency CDC upserts from operational databases Hudi Record-level indexing, MoR, Hudi Streamer
    Append-mostly analytical tables on a warehouse Iceberg Native support in Snowflake and BigQuery
    One dataset consumed by several engines expecting different formats Any + XTable / UniForm Metadata translation avoids copying data

     

    The Convergence Story

    The most consequential change in this area is not a new capability in any single format but the erosion of the boundaries between them. Several developments, taken together, have moved the ecosystem from competition toward interoperability, and they explain why the choice of format is now less of a permanent commitment than it was.

    Convergence Toward a Common Interface Databricks acquires Tabular (2024) Iceberg’s creators join the Delta vendor; over $1 billion Delta UniForm Delta tables exposed as Iceberg via the Iceberg REST catalog Hudi native Iceberg output Manage with Hudi, serve as Iceberg Apache XTable Omni-directional translation; no data copying Iceberg becomes the common denominator adopted by every major cloud and query engine

    The Databricks–Tabular acquisition

    In a development that reframed the competitive landscape, Databricks acquired Tabular — the startup founded by the original creators of Apache Iceberg — for more than one billion dollars, a deal revealed on June 4, 2024 (Databricks blog; TechTarget, as of 2024-06-04). Because Databricks is the company behind Delta Lake, the acquisition brought the architects of the rival format inside the same organization and signaled an intent to support both formats rather than insist on one. It is widely read as the moment the “format war” framing began to lose force in favour of interoperability.

    UniForm, Hudi output, and Apache XTable

    Three technical mechanisms now allow a single physical dataset to be read as more than one format. Delta UniForm, described earlier, exposes Delta tables as Iceberg and supports the Iceberg REST catalog interface (Databricks, as of 2026). Hudi’s native Iceberg support lets a team manage a table with Hudi and serve it as Iceberg (Apache Hudi documentation, as of 2026). And Apache XTable — an incubating project backed by Microsoft, Google, and Onehouse — provides omni-directional metadata translation between all three formats: any format to any format, with no copying of the underlying data (Dremio; Onehouse, as of 2026). XTable works by generating the metadata that each target format expects, pointing it at the same Parquet files, so the cost of translation is metadata generation rather than data duplication.

    Apache XTable: One Dataset, Any Format Iceberg metadata view Delta Lake metadata view Hudi metadata view Apache XTable omni-directional metadata translation Shared physical data part-1.parquet part-2.parquet part-3.parquet No data copying — only metadata is generated per format.

    The cumulative effect of these developments is that Apache Iceberg has become the de-facto common denominator. It is the format that the other two can be exposed as, the one with the broadest native reach, and the one adopted by every major cloud provider and query engine (RisingWave; Dremio; Onehouse, as of 2026). The reasons most often cited are the same throughout the literature: vendor-neutral governance, partition evolution, and the widest multi-engine support.

    Caution: Convergence reduces lock-in but does not eliminate operational specialization. Interoperability layers translate metadata, not behaviour; a workload that depends on Hudi’s record-level upsert path, for example, still benefits from writing as Hudi even if it is read as Iceberg. Translation is a serving convenience, not a substitute for choosing the right write path.

    How to Choose in 2026

    Given convergence, the practical decision reduces to selecting the format that best fits the write path and the operational center of gravity, while relying on interoperability to satisfy diverse readers. The decision tree below summarizes the guidance, and the discussion that follows expands on each branch.

    Choosing a Table Format What dominates the workload? Multi-engine, vendor-neutral Databricks / Spark-centric Streaming, high- frequency upserts Many consumers, mixed formats Iceberg Delta Lake Hudi XTable / UniForm When in doubt Default to Iceberg for new, neutral deployments; it is the format the others translate toward and the de-facto industry standard in 2026.

    Iceberg as the safe default

    For a new deployment that aims to remain vendor-neutral and to be queried by several engines, Iceberg is the safe default. It carries the lowest risk of lock-in, offers partition evolution and hidden partitioning, and is natively supported across the widest range of engines and warehouses. Choosing Iceberg also aligns with the direction of the ecosystem, since the other formats can be exposed as Iceberg but the reverse arrangement is less central to current tooling. For teams running engines on Kubernetes, where portability is already a goal, the neutrality argument extends naturally to the storage layer; the operational considerations of running such engines are discussed in the guide to database connections from Kubernetes pods.

    Delta Lake for Databricks-centric stacks

    When the platform is built around Databricks and Spark, Delta Lake remains the natural choice. Its integration is the deepest available, its tooling and governance through Unity Catalog are mature, and UniForm now mitigates the historical drawback of limited external reach by exposing the same tables as Iceberg. A team already invested in Databricks gains little by writing Hudi or Iceberg directly and may give up integration depth by doing so.

    Hudi for streaming upserts

    For ingestion dominated by high-frequency upserts and change-data-capture streams, Hudi remains the strongest fit. Its record-level indexing, Merge-on-Read table type, and built-in compaction and cleaning were designed for exactly this pattern, and its Hudi Streamer utility provides a tested ingestion path. The native Iceberg output then allows the same data to be served to analytical consumers as Iceberg, combining Hudi’s write strengths with Iceberg’s read reach.

    When to rely on interoperability instead

    Some organizations do not need to pick a single winner. When one dataset must serve consumers that expect different formats — for example, a Spark-on-Databricks team reading Delta and a Trino team reading Iceberg — relying on XTable or UniForm to translate metadata is often preferable to maintaining duplicate copies of the data. The decision then shifts from “which format” to “which write path produces the data, and which translations are needed for the readers.” This framing is the clearest sign of how far the field has moved from the original format competition.

    Tip: Choose the format that fits the write path, not the one that fits a single reader. The write path determines update efficiency and operational tooling, which interoperability layers cannot retrofit; readers in other formats can be served afterward through UniForm or XTable.

    Frequently Asked Questions

    What is a lakehouse table format, and how does it differ from a file format like Parquet?

    A file format such as Parquet defines how the bytes of a single data file are laid out. A table format is a metadata layer above many such files that presents them as one logical table with ACID transactions, schema evolution, and time travel. Parquet stores the data; the table format records which files belong to which committed version of the table and how to read them consistently.

    Is Apache Iceberg replacing Delta Lake and Hudi?

    Not exactly. Iceberg has become the de-facto common denominator that the other formats translate toward, and it is the safe default for new neutral deployments. Delta Lake and Hudi retain distinct strengths — deepest Spark integration and the most mature streaming-upsert tooling, respectively — and both can now expose their tables as Iceberg, so they continue to be used as write paths even where Iceberg is the serving format.

    What is the difference between Hudi Copy-on-Write and Merge-on-Read?

    Copy-on-Write rewrites the base file whenever a record is updated, which makes reads fast and writes heavier; it suits read-heavy tables with moderate update rates. Merge-on-Read appends updates to delta log files and merges them with the base file at query time, which makes writes light and low-latency; it suits high-frequency upserts, with background compaction periodically consolidating the deltas.

    Does choosing one format lock an organization into one vendor or engine?

    Less than it once did. Delta UniForm exposes Delta tables as Iceberg through the Iceberg REST catalog, Hudi can output Iceberg-compatible tables, and Apache XTable translates metadata among all three with no data copying. Iceberg in particular is vendor-neutral by design. Lock-in is now mainly a function of operational tooling and the write path rather than the storage format itself.

    What did the Databricks acquisition of Tabular mean for the format landscape?

    Databricks, the company behind Delta Lake, acquired Tabular — the startup founded by Iceberg’s original creators — for more than one billion dollars, with the deal revealed on June 4, 2024. Bringing Iceberg’s architects inside the Delta vendor signaled support for both formats and is widely read as the point at which the industry pivoted from a format competition toward interoperability.

    References

    Comparative characterizations of engine reach and format maturity reflect 2026 guidance from RisingWave, Dremio, Onehouse, and lakeFS; figures and dated claims are attributed inline.

    Conclusion

    The lakehouse table format is the layer that turns object storage into a transactional database surface, and the three principal implementations express three design priorities: Iceberg’s engine-neutral interoperability, Delta Lake’s depth within the Spark and Databricks ecosystem, and Hudi’s strength in high-frequency streaming upserts. Their core guarantees converge, but their metadata models and operational characters remain distinct enough to matter on the write path.

    The decisive shift of the past two years is that selecting a format no longer means accepting permanent lock-in. The Databricks–Tabular acquisition, Delta UniForm, Hudi’s Iceberg output, and Apache XTable have made it possible for one physical dataset to be presented as more than one format, with Iceberg emerging as the de-facto common denominator. The practical recommendation that follows is straightforward: default to Iceberg for new, multi-engine, vendor-neutral deployments; choose Delta Lake within Databricks-centric stacks; choose Hudi for upsert-heavy streaming ingestion; and lean on interoperability layers when one dataset must serve consumers expecting different formats. The right choice is the one that fits the write path, because interoperability can serve the readers afterward.

  • What Is a Hook in AI? Lifecycle, PyTorch, and Webhook Patterns

    The term “hook” in the context of artificial intelligence will elicit different responses depending on the audience. The agent-framework engineer typically refers to a shell command that fires before Claude Code runs a tool. The deep-learning researcher has in mind a Python callback registered on a neural network layer to capture activations. The MLOps engineer envisions an HTTP POST that lands in Slack the moment a training run finishes. The same term covers three distinct mechanisms, three distinct audiences, and three distinct sets of debugging considerations.

    This overloading is not accidental: all three variants share the same underlying idea, namely a callback that fires at a defined point in another system’s execution. Treating them as interchangeable, however, is a frequent source of confusion. Advice to “use a hook” carries little practical value without specifying which variant is intended. The present guide therefore draws the boundaries explicitly and then accompanies each variant with working code.

    Summary

    What this post covers: The word “hook” in AI refers to at least three distinct mechanisms — agent lifecycle hooks (Claude Code and similar frameworks), model introspection hooks (PyTorch forward and backward callbacks), and MLOps event hooks (webhooks fired by training jobs and model registries). This post defines each, shows working code, and gives you a decision framework for picking the right one.

    Key insights:

    • Claude Code exposes 12 lifecycle events and a small number of handler types, with exit code 2 reserved as the “block this action” signal that feeds stderr back to Claude as an error message.
    • PyTorch hooks come in three core flavors — register_forward_pre_hook, register_forward_hook, and register_full_backward_hook — each with a fixed signature and a RemovableHandle you must call .remove() on to avoid leaks.
    • MLOps webhooks are just HTTP POSTs with HMAC signatures, but they amplify failures: a slow receiver can block a model registry, and a missing signature check turns your training pipeline into an open RCE surface.
    • The three flavors are not interchangeable — picking the wrong one (a PyTorch hook to enforce safety, a webhook for activation extraction) leads to brittle systems that fight their own runtime.
    • Hooks are powerful precisely because they don’t require modifying the host system, but the same property makes them invisible — discoverability and audit logging matter as much as the hook code itself.

    Main topics: Three different things people mean by “hook” in AI, Lifecycle hooks the agent-lifecycle flavor, A working Claude Code hooks example, Model introspection hooks the PyTorch flavor, A working PyTorch hooks example, Event hooks the MLOps webhook flavor, When to use which kind of hook, Common pitfalls.

    Three different things people mean by “hook” in AI

    Vocabulary first, then code. The three variants of “hook” in AI share the same skeletal definition—a user-supplied callback that fires at a defined point in another system’s execution—but they differ in every operationally important respect: where the callback runs, which process owns it, whether it can block the host, and what data it observes.

    A lifecycle hook fires at a specific moment in an agent’s session loop. The canonical example is Claude Code’s PreToolUse event, which fires after the model has decided to invoke a tool but before the tool actually executes. The hook is a separate process—a shell command, an HTTP endpoint, or an MCP server—that the agent invokes with structured JSON describing the intended action. The hook may approve, modify, or block the action through its exit code or response. Lifecycle hooks exist because agent runtimes require extensibility points that do not necessitate forking the agent itself.

    A model introspection hook is an in-process Python callback registered on a neural network module. PyTorch’s register_forward_hook is the canonical case: a function is supplied, and PyTorch calls that function every time the module’s forward() runs, passing the module, its input, and its output. The hook lives in the same process as the model, runs synchronously within the autograd graph (the system that tracks tensor operations for gradient computation), and may read or even modify tensors on the fly. Such hooks exist because researchers need to inspect a model without rewriting its source code.

    An event hook, usually called a webhook in MLOps contexts, is an HTTP POST issued by one service to another when a defined event occurs—a training run completes, a model is promoted to production, or a drift detector exceeds a threshold. The hook receiver lives in an entirely different process (often on a different host or behind a load balancer), authenticates via a shared secret with HMAC (a cryptographic signature method that proves the message was not tampered with), and runs asynchronously with respect to the event source. Webhooks exist because MLOps stacks are heterogeneous and require a low-friction mechanism for distributing events across systems.

    Three observations render this taxonomy useful rather than pedantic. First, the audiences scarcely overlap: the researcher confronting a vanishing gradient and the platform engineer integrating a model registry both rely on “hooks,” but their tooling, vocabulary, and failure modes have little in common. Second, the level of trust required differs sharply: a PyTorch hook runs inside the process and is implicitly trusted; a Claude Code hook executes shell commands and is trusted but auditable; a webhook crosses a network boundary and must therefore authenticate. Third, the cost of misclassification scales accordingly: an errant PyTorch hook leaks memory, an errant Claude Code hook may erase a file, and an errant webhook handler may broadcast secrets. Selecting the right variant is not merely a stylistic choice; it defines the security boundary of the entire feature.

    The figure below summarises the taxonomy:

    Three Meanings of “Hook” in AI Same skeletal idea (callback at a defined point), three operational realities Lifecycle Hook (Claude Code, agent frameworks) Fires at: agent session events Runs in: separate process (shell/HTTP) Can block? yes (exit code 2) Typical user: agent builders, safety teams Example: block rm -rf, auto-format after Edit Introspection Hook (PyTorch, TensorFlow, JAX) Fires at: forward / backward pass Runs in: same process, sync, in graph Can block? no, but can modify tensors Typical user: researchers, model debuggers Example: capture activations, log gradient norms Event Hook (Webhook) (MLflow, W&B, model registry) Fires at: infra/business events Runs in: remote service, async, HTTP Can block? indirectly (timeout, retries) Typical user: MLOps, platform teams Example: Slack alert on training failure All three are “callbacks at a defined point”, but they share nothing else. Pick by problem type, not by name.

    Key Takeaway: Readers interested in only one variant may proceed directly to the relevant section. Agent builders should consult the sections on lifecycle hooks and the Claude Code example. Deep-learning practitioners should refer to the PyTorch sections. MLOps engineers should focus on the webhook section. The decision-framework section at the end is intended for all readers.

    Lifecycle hooks: the agent-lifecycle flavor

    Lifecycle hooks are the most recent of the three variants to enter the AI lexicon, largely because agent frameworks themselves are recent. The mechanism is straightforward: an agent runtime defines a small set of events that mark notable moments in its operation, and handlers are registered to fire when those events occur.

    Claude Code, the CLI agent developed by Anthropic, exposes twelve such events in its current hooks system (per the official documentation at code.claude.com/docs/en/hooks, as of 2026-05-25). The events span the full session arc, from SessionStart when the agent boots, through UserPromptSubmit when the user submits input, to PreToolUse and PostToolUse that wrap every tool call, and finally to Stop and SessionEnd. Each event passes structured JSON to the handler describing the current operation, and the handler may respond with text (returned to Claude as additional context), a block decision, or simply an exit code.

    The significance of this mechanism is as follows: without hooks, customising an agent’s behaviour requires either writing a custom tool (a heavy approach) or relying on a CLAUDE.md instruction (an unreliable one). Hooks provide a third option—deterministic, code-enforced policy that fires regardless of the model’s decisions. If a hook returns exit code 2 on a PreToolUse for any Bash call matching /rm -rf \//, the tool will not run. The model is not merely asked not to run it; the tool will not run. This distinction constitutes the entire value proposition.

    Claude Code Session Lifecycle & Hook Insertion Points Each event lets you register a handler that fires at that exact moment SessionStart agent boots UserPromptSubmit you hit enter PreToolUse CAN BLOCK tool runs PostToolUse log, format, scan Notification tool perms etc. Stop CAN BLOCK SessionEnd cleanup Other events in the 12: – SubagentStop — fires when a spawned sub-agent finishes – PreCompact — fires before context is compacted (your chance to save state) – PreRespond — fires before Claude streams its reply (modify or annotate output) – Plus additional events for slash commands, file edits, and session restoration Red = blocking-capable. Check the official docs for the current authoritative list.

    The twelve events may be categorised by responsibility as follows:

    Event When it fires Can block? Typical use case
    SessionStart Agent boots up No Inject project context, set env vars
    UserPromptSubmit After you hit enter Yes Validate prompt, expand templates
    PreToolUse Before any tool runs Yes Safety check, dry-run preview
    PostToolUse After tool returns No Auto-format, log, scan output
    Notification Permission prompts, etc. No Forward to phone, log audit trail
    Stop Claude finishes its turn Yes Force continuation, run tests
    SubagentStop A sub-agent finishes Yes Collect sub-agent artifacts
    SessionEnd Session terminates No Final cleanup, session summary
    PreCompact Before context compaction No Persist scratchpad to disk
    PreRespond Before reply streams Yes Redact, annotate, classify
    Edit/file events On file modifications No Format, lint, version control
    Slash command events On /command invocation Varies Custom command preprocessing

     

    The names matter because matching is partially name-based. A hook configuration in .claude/settings.json specifies an event name and an optional matcher (a regular expression tested against the tool name for tool-related events), followed by a list of handlers. The handler contains the code that executes.

    Handler Types and Where They Run

    Claude Code’s hooks system currently supports four handler types per the official documentation (as of 2026-05-25; readers should consult the latest reference for the authoritative list, as this area continues to evolve). The three most commonly encountered are described below:

    Claude Code Hook Handler Types Input flow (event JSON) → handler → output flow (exit code + stdout/stderr or HTTP response) Command shell command on local disk Input: JSON on stdin Output: stdout/stderr + exit code Exit semantics: 0 = ok non-zero != 2 = warn 2 = block (stderr → Claude) Pros: simple, no server needed Cons: shell-injection risk if naive cold-start cost per call HTTP POST to a web endpoint Input: JSON in request body Output: JSON in HTTP response Response semantics: 200 + {action:”allow”} 200 + {action:”block”} 5xx / timeout = error Pros: central policy, multi-user Cons: network latency in hot path availability dependency MCP Model Context Protocol server Input: MCP request message Output: MCP response message Semantics: structured tool-like reply streaming supported capability-negotiated Pros: reuses MCP tooling/infra Cons: more setup than Command harder to debug ad-hoc

    The handler type should be chosen on the basis of the desired operational profile rather than syntactic preference:

    Handler type Best for Security posture When to pick
    Command Local, per-developer policies Runs as the local user; care required with untrusted arguments Default for solo or single-machine use
    HTTP Team-wide central policy Use TLS and auth header; isolate the receiver When a single policy must be enforced across many developers
    MCP Integration with existing MCP servers Inherits the MCP server security model When MCP infrastructure is already in operation and consistency is required

     

    Readers new to MCP may find the Model Context Protocol primer a useful companion. Hooks and MCP servers represent two of the principal extensibility surfaces in modern agent runtimes, and they frequently operate in concert.

    Exit Code Semantics for Command Handlers

    The Command handler’s contract is small but precise. According to the official hooks documentation (as of 2026-05-25):

    • Exit 0: success. Stdout is captured but treated as informational, and Claude proceeds normally.
    • Exit 2: blocking error. Stderr is returned to Claude as an error message. For PreToolUse this blocks the tool call entirely; for Stop it forces continuation. This is the appropriate code for deterministic prevention.
    • Other non-zero: warning. The event is logged but not blocked, which is useful for soft policy (“not recommended, but permitted”).

    PreToolUse Hook Flow How Claude Code decides whether your hook lets a tool run Claude decides to call a tool e.g. Bash: “rm -rf /tmp/x” Matcher checked PreToolUse + matcher=”Bash” → handler is selected Handler invoked JSON event payload on stdin: {“tool”:”Bash”,”input”:{“command”:”…”}} Handler runs, reads JSON, decides exit 0 Tool runs as planned stdout → context (optional addendum) exit 1 (or other) Tool runs anyway Warning logged stderr captured exit 2 Tool is BLOCKED stderr → Claude as error message

    Caution: Exit 1 should not be conflated with exit 2. Many shell scripts exit with code 1 on any error condition. When the intent is to block, the script must specifically exit with code 2. A hook that uses set -e and then crashes will exit non-zero but probably not with code 2, so the tool will run anyway and only a warning will be logged. Blocking paths should be tested explicitly.

    A Working Claude Code Hooks Example

    Concrete code follows. The .claude/settings.json file below configures three hooks: a PreToolUse safety check, a PostToolUse auto-formatter, and a SessionStart context injector.

    {
      "hooks": {
        "PreToolUse": [
          {
            "matcher": "Bash",
            "handlers": [
              {
                "type": "command",
                "command": ".claude/hooks/safety-check.sh"
              }
            ]
          }
        ],
        "PostToolUse": [
          {
            "matcher": "Edit|Write",
            "handlers": [
              {
                "type": "command",
                "command": ".claude/hooks/auto-format.sh"
              },
              {
                "type": "http",
                "url": "https://hooks.internal.example.com/claude-edit",
                "headers": {
                  "Authorization": "Bearer ${CLAUDE_HOOK_TOKEN}"
                }
              }
            ]
          }
        ],
        "SessionStart": [
          {
            "handlers": [
              {
                "type": "command",
                "command": ".claude/hooks/inject-context.sh"
              }
            ]
          }
        ]
      }
    }

    Note the two-handler array on PostToolUse: hooks compose. Both execute, and their outputs are aggregated. The matcher is a regular expression matched against the tool name; Edit|Write means the hook fires on either event.

    PreToolUse Safety Hook in Bash

    The shell script below blocks dangerous rm patterns and writes an audit log of every Bash invocation. It reads the event JSON from stdin (using jq for parsing) and exits with code 2 and an explanatory stderr message when a risky pattern is observed.

    #!/usr/bin/env bash
    # .claude/hooks/safety-check.sh
    # Blocks dangerous rm patterns; audits all Bash invocations.
    set -uo pipefail
    
    PAYLOAD=$(cat)
    CMD=$(echo "$PAYLOAD" | jq -r '.tool_input.command // empty')
    
    # Audit log first — we want every attempt recorded.
    mkdir -p .claude/audit
    echo "$(date -u +%FT%TZ)  $CMD" >> .claude/audit/bash.log
    
    # Block obvious destructive patterns.
    DANGEROUS_PATTERNS=(
      'rm[[:space:]]+-rf?[[:space:]]+/($|[[:space:]])'
      'rm[[:space:]]+-rf?[[:space:]]+/\*'
      'rm[[:space:]]+-rf?[[:space:]]+~'
      ':\(\)\{[[:space:]]*:\|:&[[:space:]]*\};:'  # fork bomb
      'mkfs\.'
      'dd[[:space:]]+if=/dev/(zero|random|urandom)[[:space:]]+of=/dev/sd'
    )
    
    for pat in "${DANGEROUS_PATTERNS[@]}"; do
      if [[ "$CMD" =~ $pat ]]; then
        echo "Blocked: command matches dangerous pattern '$pat'" >&2
        echo "If you really need to run this, do it manually outside Claude." >&2
        exit 2
      fi
    done
    
    # Also block writes to anything under /etc or /usr without sudo prompting.
    if [[ "$CMD" =~ (^|[[:space:]])(rm|mv|cp|tee|>)[[:space:]].*(/etc/|/usr/) ]]; then
      echo "Blocked: write to system path detected." >&2
      exit 2
    fi
    
    exit 0
    

    The pattern list is intentionally short, because long pattern lists provide a false sense of security. The real defence is the audit log: even when a command is not blocked, a tamper-evident record of Claude’s attempted actions remains available.

    PostToolUse Auto-Formatter

    #!/usr/bin/env bash
    # .claude/hooks/auto-format.sh
    # Runs Prettier / Black on any file Claude just edited.
    set -euo pipefail
    
    PAYLOAD=$(cat)
    FILE=$(echo "$PAYLOAD" | jq -r '.tool_input.file_path // .tool_input.path // empty')
    
    if [[ -z "$FILE" ]] || [[ ! -f "$FILE" ]]; then
      exit 0
    fi
    
    case "$FILE" in
      *.py)        ruff format "$FILE" 2>/dev/null || true ;;
      *.ts|*.tsx)  npx prettier --write "$FILE" 2>/dev/null || true ;;
      *.js|*.jsx)  npx prettier --write "$FILE" 2>/dev/null || true ;;
      *.json)      npx prettier --write "$FILE" 2>/dev/null || true ;;
      *.go)        gofmt -w "$FILE" 2>/dev/null || true ;;
    esac
    
    # PostToolUse is not blocking — exit 0 even on format failure.
    exit 0
    

    Note the || true: a missing formatter should not cause the hook to fail. Failing a PostToolUse hook with exit code 2 has no effect (the tool has already run), but exit code 1 still produces noise in the agent’s view.

    HTTP PostToolUse Hook (FastAPI Receiver)

    For team-wide policy or central observability, an HTTP hook is preferable to a per-machine command. A minimal FastAPI receiver is shown below:

    """Webhook receiver for Claude Code PostToolUse events.
    
    Run: uvicorn receiver:app --host 0.0.0.0 --port 8080
    """
    import hashlib
    import hmac
    import json
    import logging
    import os
    from datetime import datetime, timezone
    
    from fastapi import FastAPI, Header, HTTPException, Request
    
    app = FastAPI()
    log = logging.getLogger("claude_hook")
    logging.basicConfig(level=logging.INFO)
    
    SECRET = os.environ["CLAUDE_HOOK_SECRET"].encode("utf-8")
    
    
    def verify_signature(body: bytes, signature: str) -> bool:
        """HMAC-SHA256 signature check — prevents spoofed events."""
        expected = hmac.new(SECRET, body, hashlib.sha256).hexdigest()
        return hmac.compare_digest(expected, signature or "")
    
    
    @app.post("/claude-edit")
    async def claude_edit(
        request: Request,
        authorization: str | None = Header(default=None),
        x_signature: str | None = Header(default=None),
    ):
        body = await request.body()
    
        if not verify_signature(body, x_signature or ""):
            raise HTTPException(status_code=401, detail="bad signature")
    
        event = json.loads(body)
        log.info(
            "edit by %s on %s at %s",
            event.get("session_id", "?"),
            event.get("tool_input", {}).get("file_path", "?"),
            datetime.now(timezone.utc).isoformat(),
        )
    
        # Return JSON the agent can use. An empty body is fine for fire-and-forget.
        return {"status": "logged"}
    

    The signature check is important. Without it, any party able to reach the endpoint can fabricate “Claude edited /etc/passwd” events. The shared secret resides in CLAUDE_HOOK_SECRET on both the Claude Code client and the receiver.

    SessionStart Context Injector

    #!/usr/bin/env bash
    # .claude/hooks/inject-context.sh
    # Adds current git status, branch, and any TODO.md to Claude's session context.
    set -euo pipefail
    
    cat <<EOF
    Session starting at $(date -u +%FT%TZ).
    Current branch: $(git branch --show-current 2>/dev/null || echo 'not a git repo')
    Modified files:
    $(git status --short 2>/dev/null || echo 'none')
    
    TODOs in repo:
    $(test -f TODO.md && head -20 TODO.md || echo 'no TODO.md')
    EOF
    
    exit 0
    

    Whatever the hook prints on stdout becomes part of the session’s context: the model receives it before the first user prompt. This is the most underused hook event, since it provides Claude with project-specific situational awareness without enlarging CLAUDE.md.

    For further information on customising Claude Code’s behaviour beyond hooks, refer to the custom commands guide and the skills primer. Hooks fire automatically, whereas commands and skills are user-invoked. Together, these three mechanisms cover most extension scenarios.

    Model Introspection Hooks: the PyTorch Variant

    The context now changes. Setting agents aside, consider a Python process holding a PyTorch nn.Module in which the behaviour of tensors flowing through the module must be observed. Typical use cases include capturing activations for a probing experiment, logging gradient magnitudes to debug a training run, and clipping gradients per layer for an ablation study.

    PyTorch’s nn.Module class exposes a small set of hook registration methods that address these requirements without modifying the module’s forward code. The three most commonly used methods are described below:

    API Signature Fires when Typical use case
    register_forward_pre_hook hook(module, input) Before module.forward() runs Modify or inspect inputs
    register_forward_hook hook(module, input, output) After module.forward() returns Capture activations, inspect outputs
    register_full_backward_hook hook(module, grad_input, grad_output) After gradients computed for module Log/clip gradients, debug training

     

    All three methods return a RemovableHandle. This handle should be retained, and handle.remove() should be called when the hook is no longer required. Failure to remove the handle leaves the hook firing on every forward pass indefinitely, until the module is garbage-collected. In a long-running training job, this constitutes a memory and performance leak.

    PyTorch Forward Hook Synchronous, in-process, sees the (module, input, output) tuple Input tensor x: (B, C, H, W) from upstream Module.forward(x) e.g. nn.Conv2d, nn.Linear, ResNet block, transformer layer computes y = f(x) forward hook fires hook(module, input, output) your code runs here read tensors, save copies, log statistics, modify output Output y continues downstream Key properties: – Hook runs synchronously inside the autograd graph (gradient-tracking system) — overhead is real – Returning a non-None value from the hook replaces the output (advanced use, easy to break things) – Detach tensors before storing (output.detach().clone()) to avoid blowing up memory with the graph

    The backward hook operates similarly but in the reverse direction. After loss.backward() propagates gradients back through the graph, the backward hook fires for each module that has one registered, receiving the gradients flowing into and out of that module:

    PyTorch Backward Hook Fires during loss.backward(), in reverse order through the graph loss.backward() starts at scalar loss, walks graph in reverse grad flow Module (during backprop) computes ∂L/∂x from ∂L/∂y grad_input ← grad_output (via chain rule) backward hook fires hook(module, grad_input, grad_output) read grad norms, detect explode/vanish, clip in place Differences from forward hook: – grad_input and grad_output are TUPLES (one entry per tensor arg) — index carefully – Use register_full_backward_hook, not the deprecated register_backward_hook (broken for in-place ops) – Returning a modified grad_input tuple actually replaces what flows further upstream

    The distinction between register_backward_hook (deprecated) and register_full_backward_hook (current) is a small but consequential point that wastes considerable time when overlooked. The deprecated version exhibited ordering issues with in-place operations and produced incorrect gradients for modules with non-trivial structure. The full_ variant should always be preferred.

    For readers approaching this material from outside deep learning, brief definitions are provided. The forward pass is the computation that transforms inputs into outputs—for example, running an image through ResNet to obtain class scores. The backward pass is the reverse computation that determines the contribution of each parameter to the loss, using the chain rule of calculus. Autograd is PyTorch’s gradient-tracking machinery, which records every operation performed on a tensor so that those operations can be replayed in reverse when loss.backward() is called. A gradient is the vector of partial derivatives of the loss with respect to each parameter; it is the signal that informs the optimiser of the direction in which to adjust each weight. Hooks permit observation and modification of any of these quantities at module boundaries without altering the module’s source code.

    A Working PyTorch Hooks Example

    Three concrete tasks are demonstrated below: capturing activations from a ResNet block, logging gradient norms per layer to detect training instability, and clipping gradients in place to study the effect on a small training run.

    Activation Extraction for Probing or Visualisation

    Consider a scenario in which a pretrained ResNet-50 is available and the feature map following layer4 for an input image is required—perhaps to feed into a linear probe, perhaps to visualise the network’s response. Modifying the ResNet source code is undesirable, and a forward hook is the appropriate tool.

    Activation Extraction with a Forward Hook Capture intermediate features from a frozen pretrained model Step 1: Register the hook captures = {} handle = model.layer4.register_forward_hook(lambda m,i,o: captures.update(layer4=o.detach())) Image input (1, 3, 224, 224) PIL → tensor ResNet-50 forward pass conv1 → bn1 → relu → layer1 → layer2 → layer3 → layer4 ← hook attached here final logits (1, 1000) we discard these Step 2: After forward, captures[“layer4”] holds the activation – Shape: (1, 2048, 7, 7) for a 224×224 input — 2048-channel feature map – Detached from the autograd graph (we used.detach() to avoid keeping forward state alive) – Now usable for: linear probe, CAM visualization, feature similarity search, dataset embedding – Step 3: handle.remove() when done. Forget this and you leak the hook.

    import torch
    import torchvision.models as models
    from torchvision import transforms
    from PIL import Image
    
    # Pretrained ResNet-50, eval mode.
    model = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V2)
    model.eval()
    
    # Where we will stash the activation.
    captures: dict[str, torch.Tensor] = {}
    
    def grab_layer4(module: torch.nn.Module,
                    inp: tuple[torch.Tensor, ...],
                    out: torch.Tensor) -> None:
        """Forward hook — copy the output, detach, store."""
        captures["layer4"] = out.detach().clone()
    
    # Register on the layer4 stack (a Sequential of three Bottleneck blocks).
    handle = model.layer4.register_forward_hook(grab_layer4)
    
    try:
        # Standard ImageNet preprocessing.
        preprocess = transforms.Compose([
            transforms.Resize(256),
            transforms.CenterCrop(224),
            transforms.ToTensor(),
            transforms.Normalize(mean=[0.485, 0.456, 0.406],
                                 std=[0.229, 0.224, 0.225]),
        ])
        img = Image.open("dog.jpg").convert("RGB")
        x = preprocess(img).unsqueeze(0)
    
        with torch.no_grad():
            _ = model(x)   # we discard logits; we want the captured activation
    
        act = captures["layer4"]
        print(f"layer4 activation shape: {tuple(act.shape)}")
        # → layer4 activation shape: (1, 2048, 7, 7)
    
        # Now use `act` for whatever downstream analysis you want.
    finally:
        # ALWAYS remove the hook when done.
        handle.remove()
    
    Tip: The try/finally pattern is important. If downstream code raises an exception, a dangling hook will quietly increase memory pressure on the next inference. Registrations should be wrapped in a context manager if this pattern is used frequently.

    Logging Gradient Norms with a Backward Hook

    Gradient explosions are easier to diagnose when norms can be observed per layer. A few lines of backward hook code reduce this to a single-line printout per step:

    import torch
    import torch.nn as nn
    
    class SmallNet(nn.Module):
        def __init__(self):
            super().__init__()
            self.fc1 = nn.Linear(128, 256)
            self.fc2 = nn.Linear(256, 256)
            self.fc3 = nn.Linear(256, 10)
    
        def forward(self, x):
            x = torch.relu(self.fc1(x))
            x = torch.relu(self.fc2(x))
            return self.fc3(x)
    
    model = SmallNet()
    optimizer = torch.optim.SGD(model.parameters(), lr=1e-2)
    
    # Track grad norms by layer name.
    grad_norms: dict[str, float] = {}
    handles = []
    
    def make_hook(name: str):
        def hook(module, grad_input, grad_output):
            # grad_output is a tuple of grads w.r.t. each output tensor.
            # We log the L2 norm of the first one as a simple health metric.
            if grad_output[0] is not None:
                grad_norms[name] = grad_output[0].norm().item()
        return hook
    
    for name, module in model.named_modules():
        if isinstance(module, nn.Linear):
            handles.append(module.register_full_backward_hook(make_hook(name)))
    
    # Fake training step.
    x = torch.randn(32, 128)
    y = torch.randint(0, 10, (32,))
    
    for step in range(3):
        optimizer.zero_grad()
        logits = model(x)
        loss = torch.nn.functional.cross_entropy(logits, y)
        loss.backward()
        optimizer.step()
        print(f"step {step}: " + ", ".join(f"{k}={v:.4f}" for k, v in grad_norms.items()))
    
    # Cleanup.
    for h in handles:
        h.remove()
    

    A typical output line takes the form step 0: fc1=0.0421, fc2=0.0573, fc3=0.1382. If norms expand by orders of magnitude between steps, or fall to zero for a layer that should be learning, the source of the problem is readily identifiable. This pattern is also common in transformer training: instrumenting attention and MLP blocks separately follows the same approach, simply across more modules. For further discussion of training-stack instrumentation, see the LLM training guide.

    Event Hooks: the MLOps Webhook Variant

    The third variant operates at an entirely different level. Webhooks are not located within an agent or a model; they connect services. When a training job finishes, that fact must reach a dashboard, a notification service, a downstream pipeline, and a model registry. Webhooks are the mechanism by which this distribution occurs without each service polling the others.

    The pattern is consistent across MLflow, Weights & Biases, HuggingFace, AWS SageMaker, and most model registries: when a defined event occurs, the source service sends an HTTP POST to a configured URL, with a JSON body describing the event and an HMAC signature in a header. The receiver verifies the signature, processes the event, and returns a 2xx status code (or signals failure and waits for a retry).

    MLOps Webhook Flow Event source → HTTP POST with HMAC signature → receiver fans out Training job epoch 50 complete val_acc=0.912 run_id=abc123 MLflow / W&B fires registered webhook POST + JSON body + X-Signature header (HMAC) Your webhook receiver FastAPI / Cloud Run / Lambda — anything HTTP verifies HMAC, returns 200 Fan out to multiple downstreams Slack “run abc123 hit 0.912” human notification PagerDuty on failure events only paged escalation Internal dashboard append to time series trigger eval pipeline

    Readers familiar with GitHub webhooks will recognise the design of MLOps webhooks: it is essentially the same. The header names vary by service (MLflow uses one name, Weights & Biases another, and so on), but the structure is invariant.

    Common events that vendors expose as webhooks include run.started, run.finished, and run.failed from training trackers; model.version.created, model.version.staged, and model.version.promoted from model registries; dataset.uploaded or dataset.versioned from data platforms; drift.detected or alert.fired from monitoring systems; and increasingly evaluation.completed from automated evaluation services. Each event is accompanied by a stable JSON schema, fixed per major version, and a payload-signing scheme that almost invariably follows the GitHub pattern: a SHA-256 HMAC of the raw body, hex-encoded, in a single header.

    One small but consequential decision concerns the location of the receiver. A long-running FastAPI application on a virtual machine places operational responsibility on the team when it fails outside business hours. A serverless function (Lambda, Cloud Run, Vercel) delegates availability to the platform and is charged per call, which is generally cheaper for low-volume webhook traffic. Most MLOps teams adopt serverless solutions for fan-out webhooks and reserve dedicated services for high-throughput hot paths such as real-time inference logging. The pattern is identical in either case; what differs is the operational profile.

    A webhook receiver for an MLflow “run completed” event, with strict HMAC checking, is shown below:

    """Receiver for MLflow run-completed webhooks.
    
    POST body shape (illustrative — check your MLflow version):
    {
      "event": "run.finished",
      "run": {
        "run_id": "abc123",
        "experiment_id": "42",
        "status": "FINISHED",
        "metrics": {"val_accuracy": 0.912, "val_loss": 0.231}
      },
      "timestamp": "2026-05-25T14:23:11Z"
    }
    """
    import hashlib
    import hmac
    import json
    import os
    from fastapi import FastAPI, Header, HTTPException, Request
    
    app = FastAPI()
    MLFLOW_SECRET = os.environ["MLFLOW_WEBHOOK_SECRET"].encode("utf-8")
    SLACK_WEBHOOK = os.environ.get("SLACK_WEBHOOK_URL")
    
    
    def verify(body: bytes, signature_header: str) -> bool:
        """MLflow-style: 'sha256=<hex digest>'."""
        if not signature_header.startswith("sha256="):
            return False
        expected = hmac.new(MLFLOW_SECRET, body, hashlib.sha256).hexdigest()
        return hmac.compare_digest(expected, signature_header[len("sha256="):])
    
    
    @app.post("/mlflow/run-finished")
    async def run_finished(
        request: Request,
        x_mlflow_signature: str = Header(default=""),
    ):
        body = await request.body()
        if not verify(body, x_mlflow_signature):
            # Constant-time compare above; reject fast here.
            raise HTTPException(status_code=401, detail="bad signature")
    
        event = json.loads(body)
        run = event["run"]
        metrics = run.get("metrics", {})
        val_acc = metrics.get("val_accuracy")
    
        # Fan out: alert humans on Slack only above a threshold so we don't spam.
        if SLACK_WEBHOOK and val_acc is not None and val_acc > 0.90:
            import httpx
            msg = f"Run {run['run_id']} finished with val_accuracy={val_acc:.3f}"
            async with httpx.AsyncClient(timeout=5.0) as client:
                await client.post(SLACK_WEBHOOK, json={"text": msg})
    
        # Acknowledge — keep response tiny; the sender may impose a timeout.
        return {"ok": True}
    

    Three points warrant attention. The signature check uses hmac.compare_digest rather than ==; the latter leaks timing information that allows an attacker to recover the signature byte by byte. The Slack call uses a short timeout, because a slow Slack response should not hold the MLflow connection open and trigger MLflow’s own timeout-and-retry behaviour. Finally, the receiver returns quickly: for any work heavier than a Slack notification, the operation should be pushed onto a queue and acknowledged immediately.

    Webhook-adjacent patterns also appear in workflow orchestrators. Airflow’s on_success_callback and on_failure_callback are conceptually identical: they are in-process Python callbacks rather than HTTP POSTs, but they serve the same purpose. The Airflow orchestration guide describes how those callbacks compose with cross-system webhooks.

    Selecting the Appropriate Hook: A Decision Framework

    The three variants should by this point be clearly distinct. The remaining question is operational: given a problem, which variant should be selected? The matrix below provides guidance:

    Decision Matrix — Which Hook for Which Problem Green = best fit. Yellow = workable but suboptimal. Red = wrong tool. Problem Lifecycle (Claude Code) PyTorch (introspection) Webhook Safety enforcement (agents) YES wrong layer via HTTP hook Activation extraction wrong scope YES wrong process Training-complete alert wrong scope possible but odd YES Gradient debugging wrong layer YES no visibility Auto-format after edit YES (PostToolUse) wrong layer wrong process Model registry promotion wrong scope wrong scope YES

    A simple rule covers most cases: select the hook variant that operates at the same level as the entity to be observed or modified. Tensor flow occurs inside the model process and calls for PyTorch hooks. Agent decisions occur inside the agent runtime and call for lifecycle hooks. The training-job lifecycle spans services and calls for webhooks. Mixing levels works occasionally but usually creates more integration work than it eliminates.

    A side-by-side reference for rapid selection follows:

    Property Lifecycle (Claude Code) PyTorch introspection Webhook (MLOps)
    Fires when Agent session events Per forward/backward call Infra/business events
    Runs where Shell, HTTP, or MCP Same Python process, sync Remote HTTP service, async
    Blocks execution? Yes (exit 2) No, but can modify tensors Indirectly (timeout/retry)
    Language Any (shell, Python, Go) Python only Any (it’s just HTTP)
    Typical user Agent builders, safety teams Researchers, model debuggers MLOps, platform teams
    Auth model Filesystem perms (Command) or bearer (HTTP) In-process trust HMAC signature

     

    Common Pitfalls

    Each variant has its own failure modes. Awareness of these failure modes in advance saves considerable debugging time.

    Claude Code Hook Pitfalls

    Shell injection through tool arguments. A PreToolUse hook receives JSON containing whatever Claude intends to execute. Naively interpolating fields into a shell command—such as echo "$CMD" | grep ...—exposes a path to remote code execution from prompt-injection-style attacks. JSON should always be parsed with jq or another proper parser, never with string slicing.

    Infinite hook loops. A PostToolUse hook that itself uses Claude to summarise the output, where Claude then invokes tools to summarise, and those tools trigger the PostToolUse hook again, produces a stack that is typically discovered at an inconvenient hour. Hooks should be terminal: they observe but do not re-invoke the agent.

    Exit-code confusion. Bash’s set -e exits non-zero on any failure but not necessarily with code 2. If a hook’s safety-check command crashes for an unrelated reason, the tool will run anyway because the exit code is not the blocking value. When blocking matters, the script should exit with code 2 explicitly.

    The hook is not versioned with the agent. Hook semantics evolve. A handler that worked under one Claude Code version may break under another (renamed fields in the event JSON, new required fields, and so on). Hook scripts should be pinned to the agent version against which they were tested, and re-tested after upgrades.

    PyTorch Hook Pitfalls

    Failing to call handle.remove(). This is the most common bug. A leaked forward hook is difficult to detect: the model continues to function, but more slowly, and memory usage drifts upwards. handle.remove() should be treated like close() and written on the same line as the registration where possible, or wrapped in a context manager.

    Storing tensors with the graph attached. Storing output rather than output.detach() retains the entire computation graph leading to that output. On a fifty-layer model the consequences are severe. Tensors should always be detached, and usually cloned, before storage.

    Hooks added in __init__ versus registered post hoc. Hooks registered on a module from outside do not survive a deep copy of the model (a common pattern in distributed training). Hooks installed in the module’s own __init__ do survive, because they form part of the module’s state. If the training launcher uses copy.deepcopy or torch.nn.parallel.replicate, registration should occur inside the module.

    Overhead in tight loops. Every hook adds Python-level overhead per call. This is acceptable for offline analysis but problematic in a training loop with tens of thousands of iterations per epoch. Hooks should be registered only on the modules of interest, only for the steps of interest, and removed immediately afterwards.

    For training-loop instrumentation that extends beyond gradient logging, the self-supervised learning guide presents similar patterns applied to representation extraction during pretraining.

    Webhook Pitfalls

    Timeout amplification. The sender (MLflow, Weights & Biases, or the model registry in question) typically imposes a short timeout, often five or ten seconds. If the receiver performs any slow operation inline—a database write, a slow Slack call, or ML inference—events will be missed and retries triggered. The recommended pattern is to receive quickly, queue the work, and return a 2xx status code.

    Missing signature verification. An unverified webhook endpoint is a public remote-code-execution risk if the handler performs any privileged operation with the payload. HMAC should be verified on every request, compared with hmac.compare_digest, and the source IP should not be relied upon.

    At-least-once semantics. Almost every webhook sender retries on failure, so the receiver will observe the same event more than once. The handler must be idempotent: the same event delivered twice should not double-count, double-notify, or double-promote.

    Replay attacks. Even with HMAC, a captured request can be replayed. A timestamp should be included in the signature payload (most senders do this already), and events older than a small window should be rejected.

    Caution: Across all three variants, the most common silent failure is the same: the hook is in place but is not actually executing. A misconfigured matcher, a leftover handle, or a webhook endpoint that senders no longer reach can all produce this outcome. Observability should be added through audit logs and gauge metrics on hook invocation counts so that a non-firing hook is detected.

    Frequently Asked Questions

    Are Claude Code hooks the same as MCP servers?

    No. MCP servers extend what an agent can do by exposing new tools, resources, and prompts that the agent can call. Hooks extend the agent’s lifecycle by inserting policy at predefined moments. Both can be used simultaneously; a common pattern is an MCP server that provides project context paired with a PreToolUse hook that enforces safety on the agent’s tool calls. The two systems are complementary rather than redundant.

    Does register_forward_hook affect gradients?

    It can. If the hook returns a tensor in place of None, that tensor replaces the module’s output for the remainder of the forward pass, and gradients flow through the replacement during backpropagation. If the hook only reads tensors and returns None, gradients are unaffected. The same applies to backward hooks: returning a modified grad_input tuple replaces what propagates further back. For read-only inspection, the hook should return nothing.

    Can webhooks block a training job?

    Indirectly. If a model-registry promotion event has a configured webhook receiver that times out, some registries pause the promotion pending retries while others fail the promotion entirely. In either case, the system being hooked into determines whether a slow receiver can stall the workflow. The documentation for the specific service should be consulted. As a general rule, webhooks should be treated as fire-and-forget signals rather than synchronous gates.

    What is the relationship between hooks and callbacks?

    The terms are largely synonymous, with a difference of connotation. “Callback” implies a function registered by the user, often for a single defined moment. “Hook” implies a registered extension point exposed by the host system, often one of many. PyTorch documentation uses “hook”; asyncio documentation uses “callback”; the underlying concept is the same. In MLOps, Airflow uses “callback” (on_success_callback) while GitHub uses “webhook”—the same pattern, expressed in different vocabulary.

    Are there security risks specific to lifecycle hooks?

    Yes, three principal risks. First, hooks run with the agent’s privileges, which usually corresponds to the user’s shell, so a bug in a hook script can cause real damage on the machine. Second, hook payloads contain whatever the model intends to do, including potentially adversarial content arising from prompt injection; naive shell interpolation is dangerous. Third, hooks are invisible: a colleague inspecting an agent session will not see the hook fire unless it is logged. Audit logging and code review for hook scripts are as important as for production code. The harness engineering guide covers the broader threat model.

    References

    Conclusion

    “Hook” in AI is a small term performing three distinct functions. Lifecycle hooks allow deterministic policy to be inserted into an agent’s session without forking the agent. Model introspection hooks allow tensor flow to be read or modified without forking the model. Event hooks allow services to communicate about significant moments without polling. The mechanisms share a name and a skeletal definition—a callback at a defined point—but they differ in process, language, blocking semantics, security model, and audience.

    The practical guidance reduces to three rules. First, select the variant that matches the layer at which the problem resides; agent safety should not be enforced with a PyTorch hook, nor should activations be extracted via a webhook. Second, treat hook code as production code: review it, audit it, log it, and version it alongside the system it extends. Third, recall that hooks are powerful precisely because they are invisible to the host; that invisibility is also their principal failure mode, so observability should be built in to detect when a hook ceases to fire.

    One habit worth taking from this guide is the following: whenever the advice to “use a hook” appears in documentation or in a blog post, the appropriate first question is which variant. The answer almost always determines the correct design.

  • How to Train Open-Source LLMs in 2026: Qwen3.6, Qwen3.5, GPT-OSS

    Two years ago, training a large language model required either renting time at a research lab or accepting that fine-tuning was the preserve of billion-dollar companies. By May 2026, Qwen3.6-27B can be taken from a Hugging Face download to a domain-specialised model on a single rented H100 for less than fifteen dollars. The tools have changed. The underlying mathematics has not, but the population of those who use it has expanded. This article describes how to train an open-source LLM in practice today: what hardware is required, which model to choose, how to format the data so that the trainer does not silently discard it, and how to place the result behind a serving endpoint that responds in milliseconds.

    Summary

    What this post covers: A working 2026 playbook for fine-tuning open-source LLMs using three concrete anchors — the dense Qwen3.6-27B, the MoE Qwen3.5-122B-A10B, and OpenAI’s GPT-OSS-120B — from environment setup through deployment.

    Key insights:

    • QLoRA on a single H100 (80GB) now fine-tunes a 27B dense model in 8 to 12 hours for $10 to $16 of cloud rental, retaining 80 to 90 percent of full fine-tuning quality.
    • MoE models like Qwen3.5-122B-A10B (10B active) and GPT-OSS-120B (5.1B active) need VRAM to hold all 122B or 117B weights, even though per-token compute is small — the “active parameter” headline number is a runtime FLOPs claim, not a memory one.
    • Chat-template mismatch between training and inference is the single most common cause of a “trained but acts untrained” model — Qwen’s <|im_start|> markers and GPT-OSS’s harmony format are not interchangeable.
    • GPT-OSS-120B ships post-trained with MXFP4 quantization on the MoE weights, which is why a 117B-total-parameter model fits in a single 80GB H100 at inference time.
    • For anything past 70B at full precision, FSDP2 or DeepSpeed ZeRO-3 sharding is no longer optional — single-node training caps out around 32B dense in FP16 even on H200 (141GB) hardware.

    Main topics: The State of Open-Source LLM Training in 2026, Meet the Three Anchor Models, Choosing Full Fine-Tune LoRA or QLoRA, Setting Up the Training Environment, Preparing the Dataset, The Actual Training Run, Evaluation That Isn’t Theatre, Deployment, Common Pitfalls and Debugging.

    The State of Open-Source LLM Training in 2026

    The open-source LLM landscape in May 2026 bears little resemblance to that of early 2024. Two structural shifts have transformed what a single engineer can accomplish alone.

    The first shift is architectural. Mixture-of-Experts (MoE) models, in which each token activates only a small subset of total parameters, have become the dominant configuration for any model larger than 30B. A dense model uses every weight on every token; an MoE model uses a router to direct each token to a small fraction of “expert” sub-networks. Qwen3.5-122B-A10B has 122B total parameters but only approximately 10B active per forward pass. GPT-OSS-120B contains 117B total parameters with 5.1B active. The runtime FLOPs resemble those of a small model; the VRAM footprint does not.

    The second shift concerns post-training tooling. QLoRA, in which the base weights are frozen at 4-bit NF4 (NormalFloat-4, a quantisation format optimised for the distribution of neural network weights) and only a small low-rank adapter is trained, has moved from a research curiosity in 2023 to the default starting point in 2026. LoRA (Low-Rank Adaptation) retains 90 to 95 per cent of full fine-tuning performance. QLoRA retains 80 to 90 per cent while reducing VRAM by approximately 75 per cent compared with FP16.

    The practical implication is as follows: a 7B model that required approximately 14GB of VRAM to fine-tune in FP16 now fits in 5 to 6GB under QLoRA. A 70B model that required approximately 140GB now fits in 46GB. The hardware threshold has dropped sufficiently that the question has shifted from whether training is affordable to what should be trained.

    Three Open-Source LLMs at a Glance (May 2026) Qwen3.6-27B Dense, multimodal Total params: 27B Active per token: 27B Architecture: Dense Attention: Gated DeltaNet (linear + self-attn hybrid) Context: 262K native (extensible to 1M) Modalities: Vision + text Released: 2026-04-22 License: Apache 2.0 Best for: Single-GPU fine-tuning, multimodal agents, long context tasks Qwen3.5-122B-A10B MoE, sparse Total params: 122B Active per token: ~10B Architecture: MoE Attention: Gated DeltaNet (linear + self-attn hybrid) Context: 262K native (extensible to 1M+) Modalities: Text Released: 2026-02-24 License: Apache 2.0 Best for: Cheap inference, scale via tensor parallelism, reasoning workloads GPT-OSS-120B MoE, MXFP4 native Total params: 117B Active per token: 5.1B Architecture: MoE Attention: Standard (grouped-query) Context: 128K Modalities: Text Released: Aug 2025 License: Apache 2.0 Best for: Single 80GB GPU serving, reasoning near o4-mini, drop-in OpenAI replacement

    The implications for a practitioner intending to train a model today are as follows: prosumer hardware—a single H100 or H200, or even a 48GB consumer card such as the RTX 6000 Ada—can handle QLoRA on models up to 70B. Beyond that point, multi-GPU LoRA or sharded full fine-tuning is required. Specific recipes for each scenario are presented below.

    Pretraining from scratch—the 2.1 million H100-hour run that produced GPT-OSS-120B—remains out of reach for almost all practitioners. Within reach, however, is taking one of these three checkpoints and adapting it to a particular dataset, domain, or task. This is what “training an open-source LLM” means in practice in 2026.

    Key Takeaway: Training in 2026 almost always means fine-tuning a released checkpoint. The interesting choice is not pretraining versus fine-tuning but rather which fine-tuning method and which base model to use.

    The Three Anchor Models

    Three models cover the practical range of what is fine-tuned today: a dense 27B model that fits comfortably on prosumer hardware, a sparse 122B model that requires cluster-class memory but inexpensive compute, and a 117B MoE model that ships pre-quantised to fit on a single 80GB card.

    Qwen3.6-27B

    Released on 22 April 2026 by Alibaba’s Qwen team. Dense: every one of the 27 billion parameters participates in every forward pass. It uses Gated DeltaNet, a hybrid attention scheme that combines a linear-attention path (constant memory cost per token) with traditional softmax self-attention. The linear path handles long-range context, while the softmax path preserves short-range precision.

    Native context is 262,144 tokens, extensible to one million via position-encoding extrapolation. The model is natively multimodal: the same checkpoint accepts images and text. A “Thinking Preservation” mechanism maintains a chain-of-thought reasoning mode and a fast non-thinking mode within a single set of weights.

    Benchmark figures from the Qwen team include SWE-bench Verified 77.2 (compared with Qwen3.5-397B-A17B at 76.2), SWE-bench Pro 53.5 (compared with 50.9), Terminal-Bench 2.0 59.3 (compared with 52.5), and SkillsBench 48.2 (compared with 30.0). A 27B dense model surpassing its 397B MoE predecessor on code-related work is the kind of result that re-establishes the importance of architecture choice.

    The model can be downloaded from the QwenLM/Qwen3.6 official repository or the Hugging Face Qwen/Qwen3.6-27B mirror. The licence is Apache 2.0: commercial use is permitted with attribution.

    Qwen3.5-122B-A10B

    Released on 24 February 2026. A sparse MoE: 122 billion total parameters, approximately 10 billion active per forward pass. The “A10B” suffix denotes the active-parameter count. Each token is routed through a small subset of experts, while the remainder of the network remains idle for that token.

    The model shares the Gated DeltaNet hybrid attention of Qwen3.6-27B and the same 262K native context, extensible to 1M+. It is text-only at this size. The MoE structure means inference compute resembles that of a 10B model, but VRAM must still hold all 122B weights, because the router cannot determine in advance which expert any given token will require.

    This is the appropriate model when strong quality is required alongside inexpensive per-token serving. The active-parameter count determines latency and energy cost; the total parameter count determines hardware purchasing decisions. The trade-off is frequently misunderstood on first encounter.

    GPT-OSS-120B

    OpenAI’s first open-weight LLMs since GPT-2 (2019), released in August 2025. The model contains 117 billion total parameters with 5.1 billion active, under an Apache 2.0 licence. It was trained on NVIDIA H100 GPUs using PyTorch with custom Triton kernels. The training run consumed 2.1 million H100-hours, which at $2 per hour in cloud pricing represents approximately $4.2 million in compute alone.

    What makes GPT-OSS-120B unusual is that it ships post-trained with MXFP4 quantisation on the MoE weights. MXFP4 is a 4-bit floating-point format with a shared scale per micro-block. Because the bulk of the parameter count resides in the MoE expert layers, quantising those layers to 4-bit reduces the on-disk and in-VRAM footprint sufficiently to fit on a single 80GB GPU (H100 or AMD MI300X). The non-expert layers remain at higher precision.

    The benchmark posture indicates near-parity with OpenAI’s o4-mini on core reasoning. For a model that can run on a single rented GPU, this is a notable result. The model card and weights are available at huggingface.co/openai/gpt-oss-120b; the official repository is at github.com/openai/gpt-oss; the launch announcement is at openai.com/index/introducing-gpt-oss.

    Attribute Qwen3.6-27B Qwen3.5-122B-A10B GPT-OSS-120B
    Total params 27B 122B 117B
    Active params 27B (dense) ~10B 5.1B
    Architecture Dense, Gated DeltaNet MoE, Gated DeltaNet MoE, grouped-query attn
    License Apache 2.0 Apache 2.0 Apache 2.0
    Release date 2026-04-22 2026-02-24 August 2025
    Native context 262K (extensible to 1M) 262K (extensible to 1M+) 128K
    Multimodal Yes (vision + text) Text only Text only
    Download HF: Qwen/Qwen3.6-27B HF: Qwen/Qwen3.5-122B-A10B HF: openai/gpt-oss-120b

     

    Choosing Full Fine-Tune, LoRA, or QLoRA

    Three fine-tuning methods cover essentially the entire field. They occupy positions along a cost-versus-quality spectrum, and the appropriate choice depends on the volume of available data and the degree to which the target domain differs from the base model’s training distribution.

    Full fine-tuning updates every parameter. It requires approximately four times the model’s memory footprint during training: model weights, gradients, optimizer states (two for AdamW: first and second moment), and activations. A 7B model requires approximately 14GB in FP16 for weights alone; with optimizer states and gradients, peak usage approaches 60GB.

    LoRA (Low-Rank Adaptation) freezes the base weights and inserts trainable low-rank matrices into the attention projection layers. Instead of updating the full weight matrix W (for example, 4096×4096 = approximately 16.7M parameters), two small matrices B (4096×r) and A (r×4096) are trained, where r is typically 8, 16, or 32. The model effectively learns ΔW = B·A, which is added to the frozen W at inference. For r = 16, this amounts to approximately 131K trainable parameters per layer rather than 16.7M, roughly 128 times fewer.

    QLoRA extends LoRA further. The frozen base weights are quantised to 4-bit NF4 (NormalFloat-4, designed to match the typical Gaussian distribution of neural network weights), and LoRA adapters sit on top in FP16 or BF16. The weights are de-quantised on the fly only during forward and backward passes. Memory consumption decreases by approximately 75 per cent compared with FP16 training.

    Cost vs Quality Spectrum: Fine-Tuning Methods Lower VRAM & cost Higher VRAM & cost 100% 50% 0% Quality retention (% of full FT) Prompting / RAG ~0 VRAM Quality: ~60-70% QLoRA 80-90% 7B: ~6GB | 70B: ~46GB $10-16 single H100 LoRA 90-95% 7B: ~16GB | 70B: ~160GB 2-4× H100 for 70B Full FT 100% (baseline) 7B: ~60GB | 70B: ~560GB 8× H100, $250-510

    Method VRAM (7B) VRAM (70B) Wall time (1 H100) Cost (cloud) Quality retention
    Full FT ~60 GB ~560 GB (needs 8×H100) 24-48h on 8×H100 $250-510 100% (baseline)
    LoRA ~16 GB ~160 GB (2-4 GPUs) 10-15h $20-40 90-95%
    QLoRA ~6 GB ~46 GB (1 H100/H200) 8-12h $10-16 80-90%

     

    How LoRA and QLoRA Work W₀ (frozen) Base weights d × d e.g. 4096 × 4096 = 16.7M params LoRA: FP16 QLoRA: 4-bit NF4 No gradient. No optimizer state. + B d × r init to 0 · A r × d Gaussian init = W = W₀ + B·A Effective weight For r = 16: B = 4096×16 = 65K A = 16×4096 = 65K 131K trainable vs 16.7M dense ~128× fewer Per attention projection Quantize to NF4 (QLoRA only) ~75% VRAM saved

    The practical selection heuristic is to begin with QLoRA. If quality is insufficient after a sweep over rank, learning rate, and data size, the next step is LoRA. Full fine-tuning should be reserved for cases in which the domain shift is so substantial that the base model’s representation is genuinely wrong—for example, a model trained predominantly on English required to operate in a low-resource language. The 80 to 90 per cent quality retention of QLoRA is sufficient for the majority of production tasks.

    Tip: A LoRA rank (r) of 16 serves as a sensible default. It should be increased to 32 or 64 only if the task differs substantially from the base model’s training distribution. Higher rank consumes more VRAM and rarely provides benefits beyond r ≥ 16 for most domains.

    VRAM Budget by Model and Mode 600 480 360 240 120 60 0 VRAM (GB) H100 = 80GB H200 = 141GB Qwen3.6-27B 54 14 22 ~270 Qwen3.5-122B 244 62 ~80 ~600+ GPT-OSS-120B 234 35* ~75 ~560 Inference FP16 Inference 4-bit QLoRA training Full FT training (peak) * MXFP4 native

    It is worth noting that GPT-OSS-120B’s 4-bit inference figure (approximately 35 GB) is substantially lower than Qwen3.5-122B’s 62 GB despite similar total parameter counts. This is the advantage of MXFP4-native quantisation. Qwen3.5 must be quantised after training (AWQ or GPTQ), incurring some additional accuracy loss; GPT-OSS-120B was post-trained with the 4-bit format already in mind.

    Setting Up the Training Environment

    Three years ago, this section would have been considerably more complex: CUDA versions, PyTorch builds, mismatched Triton, and broken bitsandbytes. In May 2026 the process remains finicky, but the recipe is more stable.

    The requirements are CUDA 12.6 or newer (CUDA 12.8 ships well with the H100/H200 SXM5 drivers), cuDNN 9.5 or newer, PyTorch 2.7 stable or 2.8 nightly, and recent versions of transformers, peft, accelerate, trl, bitsandbytes, and vllm. Flash Attention 3 requires Hopper (H100/H200) or newer; on Ampere (A100), Flash Attention 2 is the fallback.

    The cleanest approach uses a Docker container that pins all of these versions. Building locally is the second-cleanest option. Operating in a bare Python environment invites an evening of debugging mismatched CUDA symbols. Containerising the training environment with a known-good base image, typically nvidia/cuda:12.8.0-cudnn-devel-ubuntu24.04, is the standard approach.

    A working pyproject.toml for a fine-tuning project as of May 2026 is shown below:

    [project]
    name = "llm-finetune"
    version = "0.1.0"
    requires-python = ">=3.11"
    dependencies = [
        "torch==2.7.0",
        "transformers==4.50.2",
        "peft==0.14.1",
        "bitsandbytes==0.46.0",
        "accelerate==1.4.0",
        "trl==0.16.0",
        "datasets==3.5.0",
        "unsloth==2026.5.3",
        "flash-attn==3.0.1",
        "vllm==0.9.2",
        "wandb==0.19.5",
        "sentencepiece==0.2.0",
        "tiktoken==0.7.0",
        "lm-eval==0.4.7",
    ]
    
    [tool.uv]
    index-strategy = "unsafe-best-match"
    
    [[tool.uv.index]]
    name = "pytorch-cuda128"
    url = "https://download.pytorch.org/whl/cu128"
    

    A Dockerfile producing a known-good training image is shown below:

    FROM nvidia/cuda:12.8.0-cudnn-devel-ubuntu24.04
    
    ENV DEBIAN_FRONTEND=noninteractive \
        PYTHONUNBUFFERED=1 \
        PIP_NO_CACHE_DIR=1 \
        HF_HOME=/workspace/.cache/huggingface \
        TORCH_CUDA_ARCH_LIST="9.0;10.0"
    
    RUN apt-get update && apt-get install -y --no-install-recommends \
            python3.11 python3.11-venv python3-pip git curl ca-certificates \
            build-essential ninja-build cmake \
        && rm -rf /var/lib/apt/lists/*
    
    RUN curl -LsSf https://astral.sh/uv/install.sh | sh
    ENV PATH="/root/.local/bin:${PATH}"
    
    WORKDIR /workspace
    COPY pyproject.toml uv.lock ./
    RUN uv sync --frozen --no-dev
    
    # Flash Attention 3 needs to compile against the installed torch
    RUN uv pip install --no-build-isolation flash-attn==3.0.1
    
    COPY . .
    
    CMD ["uv", "run", "python", "-m", "train"]
    

    The framework landscape in 2026 is as follows: TRL is HuggingFace’s official trainer for SFT (supervised fine-tuning) and reinforcement learning post-training. Axolotl is a YAML-config layer on top of TRL that handles much of the data-preparation boilerplate. Unsloth is a Triton-optimised custom kernel package that claims up to twice the training speed and 60 per cent lower VRAM consumption through hand-tuned kernels, and is now stable enough for production use. torchtitan is Meta’s reference scaffolding for large-scale pretraining and full fine-tuning with FSDP2.

    Framework Primary use case Scaling target Ergonomics Recent activity
    TRL SFT, DPO, GRPO, PPO 1-8 GPUs, single node Python API, flexible Very active
    Axolotl SFT, DPO with YAML config 1-8 GPUs YAML, low boilerplate Active
    Unsloth Single-GPU QLoRA/LoRA, speed 1 GPU (multi-GPU in 2025) Drop-in trainer wrapper Very active
    torchtitan Full FT, pretraining at scale Multi-node FSDP2 Lower-level, reference impl Active

     

    For 99 per cent of fine-tuning work, the appropriate choice is TRL (or Unsloth-wrapped TRL for single-GPU work). torchtitan should be reserved for full fine-tuning at 70B and above, where FSDP2 sharding becomes mandatory.

    Preparing the Dataset

    The training loop is the straightforward part. Dataset preparation is where most fine-tuning runs silently fail. The model trains for hours and the loss curve appears acceptable, but at evaluation the outputs are wrong, off-topic, or in the wrong format. The problem is almost always located upstream of the trainer.

    A modern instruction-tuning dataset consists of a list of conversations. Each conversation is a list of turns, and each turn has a role (system, user, assistant) and content. JSONL is the standard on-disk format, with one JSON object per line. Parquet is the high-performance alternative for large datasets requiring columnar compression.

    A single training example for Qwen3.6-27B appears as follows:

    {"messages": [
      {"role": "system", "content": "You are an industrial controls expert."},
      {"role": "user", "content": "Explain why a PID loop on a cobot joint might oscillate after a payload change."},
      {"role": "assistant", "content": "When the end-effector payload changes, the effective inertia at the joint changes. The PID gains were tuned for the original inertia. With higher effective mass, the proportional term applies the same torque correction to a slower-responding system, which can overshoot the setpoint and drive sustained oscillation. Two practical fixes: gain scheduling (load the right PID set when the gripper picks up the part) or an adaptive controller (online inertia estimation). The first is easier to validate in safety review."}
    ]}
    

    The tokenizer then applies the model’s chat template—a Jinja-style template defined inside tokenizer_config.json—to convert that list of turns into a single tokenised sequence with the model’s special tokens. For Qwen3.6, the chat template wraps each turn in <|im_start|>role\ncontent<|im_end|>. For GPT-OSS-120B, the harmony format with <|start_of_turn|> and channel markers is used. These are not interchangeable. A model trained with the wrong template and inferred with the correct one will behave as though it had never been trained.

    Chat Template: From Conversation to Training Sequence Input: Structured messages role: system “You are a Python expert.” role: user “Why does my asyncio.gather() block?” role: assistant “asyncio.gather() awaits the collected futures. If you wrap a blocking call without to_thread() the whole loop stalls…” apply_chat_template() + tokenizer.encode() Qwen chat template output <|im_start|>system You are a Python expert. <|im_end|> <|im_start|>user Why does my asyncio.gather() block? <|im_end|> <|im_start|>assistant asyncio.gather() awaits the collected futures. If you wrap a blocking call without to_thread() the whole loop stalls… <|im_end|> Loss mask: System + user tokens: ignore_index = -100 Assistant tokens: train normally CRITICAL: GPT-OSS uses harmony format, NOT <|im_start|>. Templates are not portable.

    The standard loss-masking pattern is as follows: the model is trained to predict assistant tokens, but the loss is masked (set to -100, the standard ignore_index for PyTorch’s CrossEntropyLoss) on system and user tokens. It is undesirable to teach the model to generate user messages.

    A representative data-loading pipeline for Qwen3.6-27B, using the HuggingFace datasets library, is shown below:

    from datasets import load_dataset
    from transformers import AutoTokenizer
    
    MODEL_ID = "Qwen/Qwen3.6-27B"
    tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, trust_remote_code=True)
    
    def format_example(example):
        """Apply Qwen's chat template and tokenize."""
        text = tokenizer.apply_chat_template(
            example["messages"],
            tokenize=False,
            add_generation_prompt=False,
        )
        return {"text": text}
    
    ds = load_dataset("json", data_files="data/train.jsonl", split="train")
    ds = ds.map(format_example, remove_columns=ds.column_names)
    
    # Train/eval split with a fixed seed for reproducibility
    split = ds.train_test_split(test_size=0.05, seed=42)
    train_ds, eval_ds = split["train"], split["test"]
    
    print(f"Train: {len(train_ds)}, Eval: {len(eval_ds)}")
    print("Sample formatted text:")
    print(train_ds[0]["text"][:500])
    

    Before training, two additional passes should be performed on the dataset. First, deduplication: exact-match dedup is inexpensive (a hash per example), while MinHash or SimHash near-dedup catches paraphrases. Duplicates inflate the loss curve and bias the model toward memorising common patterns.

    Second, a contamination check: it must be ensured that none of the training data overlaps with the evaluation benchmarks. If the evaluation is MMLU and the training data was scraped from Common Crawl, there is a real probability that MMLU questions are present. A substring search of evaluation questions against the training set should be conducted, with any matches removed.

    When data preparation is sufficiently complex to warrant orchestration, Airflow data pipelines are a suitable fit, as the dedup, contamination check, and tokenisation steps map well to a directed acyclic graph.

    Caution: The most common training failure is also the most silent: chat template mismatch. The output fed to the trainer should always be verified with tokenizer.apply_chat_template to confirm that it matches the format expected by the model. The first 1000 characters of a tokenised example should be printed before any long run.

    The Actual Training Run

    Three concrete recipes are presented below, covering the three anchor models across three hardware budgets. Each provides a known-working starting point from which learning rate, rank, and data mixture may be tuned.

    End-to-End Training Pipeline 1. Data prep dedup, filter hours-days (offline) 2. Tokenize chat template minutes (cached) 3. Forward compute logits ~50-200ms/step 4. Loss backward + grads ~70-300ms/step 5. Optimizer AdamW step ~10-30ms/step Repeat for N steps per epoch 6. Eval held-out set loss every N steps 7. Checkpoint save adapter / weights every K steps or best eval 8. Benchmark lm-eval-harness end of training Total wall time (QLoRA 27B, single H100, 50K examples, 3 epochs): ~8-12 hours end-to-end | per-step: ~150-400ms | eval: every 500 steps | checkpoint: every 1000 steps

    Recipe 1: QLoRA on Qwen3.6-27B, Single H100 (80GB)

    This is the most accessible setup. One rented H100 from Lambda Labs, RunPod, or a comparable cloud provider costs approximately $1.80 to $2.50 per hour as of May 2026. With 50,000 training examples and three epochs, the target wall time is eight to twelve hours, for a total bill of $10 to $16. This is the recipe most teams actually use.

    # train_qlora_qwen36.py
    import torch
    from transformers import (
        AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
    )
    from peft import LoraConfig, prepare_model_for_kbit_training
    from trl import SFTConfig, SFTTrainer
    from datasets import load_dataset
    
    MODEL_ID = "Qwen/Qwen3.6-27B"
    OUTPUT_DIR = "out/qwen36-27b-qlora"
    
    bnb_config = BitsAndBytesConfig(
        load_in_4bit=True,
        bnb_4bit_quant_type="nf4",        # NormalFloat-4
        bnb_4bit_compute_dtype=torch.bfloat16,
        bnb_4bit_use_double_quant=True,   # nested quantization of the quant constants
    )
    
    tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, trust_remote_code=True)
    tokenizer.padding_side = "right"  # important: right-pad for SFT
    
    model = AutoModelForCausalLM.from_pretrained(
        MODEL_ID,
        quantization_config=bnb_config,
        torch_dtype=torch.bfloat16,
        attn_implementation="flash_attention_3",
        device_map="auto",
        trust_remote_code=True,
    )
    model = prepare_model_for_kbit_training(model)
    model.config.use_cache = False  # cache is not used during training; saves VRAM
    
    peft_config = LoraConfig(
        r=16,
        lora_alpha=32,             # alpha/r = 2 is a common starting ratio
        lora_dropout=0.05,
        bias="none",
        task_type="CAUSAL_LM",
        target_modules=[
            "q_proj", "k_proj", "v_proj", "o_proj",
            "gate_proj", "up_proj", "down_proj",
        ],
    )
    
    train_ds = load_dataset("json", data_files="data/train.jsonl", split="train")
    eval_ds  = load_dataset("json", data_files="data/eval.jsonl",  split="train")
    
    sft_config = SFTConfig(
        output_dir=OUTPUT_DIR,
        num_train_epochs=3,
        per_device_train_batch_size=2,
        gradient_accumulation_steps=8,   # effective batch = 16
        gradient_checkpointing=True,     # trade compute for VRAM
        learning_rate=2e-4,              # LoRA-typical; full FT would use ~1e-5
        lr_scheduler_type="cosine",
        warmup_ratio=0.03,
        optim="paged_adamw_8bit",        # 8-bit optimizer to save more VRAM
        bf16=True,
        max_seq_length=4096,
        packing=True,                    # pack short examples to maximize GPU use
        eval_strategy="steps",
        eval_steps=500,
        save_steps=1000,
        save_total_limit=3,
        logging_steps=20,
        report_to="wandb",
        seed=42,
    )
    
    trainer = SFTTrainer(
        model=model,
        tokenizer=tokenizer,
        args=sft_config,
        train_dataset=train_ds,
        eval_dataset=eval_ds,
        peft_config=peft_config,
    )
    
    trainer.train()
    trainer.save_model(OUTPUT_DIR)
    

    The principal design choices in the script merit explanation:

    • NF4 with double quantisation: NF4 quantises the weights themselves; double quantisation additionally quantises the per-block scaling constants, saving a further approximately 0.4 bits per parameter on average.
    • Gradient checkpointing: activations are recomputed during the backward pass rather than stored. This reduces activation memory by approximately the square root of the sequence length at a cost of roughly 30 per cent additional compute. The trade is almost always worthwhile for LoRA and QLoRA.
    • Gradient accumulation: with a per-device batch size of 2 and accumulation steps of 8, the effective batch is 16. This is useful when VRAM constrains the per-step batch but the optimisation signal of a larger batch is desired.
    • Paged AdamW 8-bit: optimiser states (first and second moments) at 8-bit precision, with paging to CPU when not in use. Reduces optimiser-state memory by a factor of four compared with FP32 AdamW.
    • Packing: concatenates multiple short examples into one sequence up to max_seq_length. Without packing, padding to 4096 tokens wastes most of the compute on short examples.

    Recipe 2: Multi-GPU LoRA on Qwen3.5-122B-A10B

    122B total parameters corresponds to approximately 244GB in FP16 for the weights alone. Two H200s (141GB each, 282GB combined) or four H100s (320GB combined) handle this comfortably with tensor parallelism. The accelerate configuration below specifies FSDP2 with the model sharded across eight GPUs.

    # accelerate_config_fsdp.yaml
    compute_environment: LOCAL_MACHINE
    distributed_type: FSDP
    mixed_precision: bf16
    num_processes: 8
    num_machines: 1
    machine_rank: 0
    gpu_ids: all
    
    fsdp_config:
      fsdp_version: 2
      fsdp_auto_wrap_policy: TRANSFORMER_BASED_WRAP
      fsdp_transformer_layer_cls_to_wrap: Qwen3MoeDecoderLayer
      fsdp_sharding_strategy: FULL_SHARD
      fsdp_state_dict_type: SHARDED_STATE_DICT
      fsdp_offload_params: false
      fsdp_use_orig_params: true
      fsdp_sync_module_states: true
      fsdp_cpu_ram_efficient_loading: true
      fsdp_activation_checkpointing: true
    

    Launch the run with: accelerate launch --config_file accelerate_config_fsdp.yaml train_lora_qwen35.py

    The training script is structurally similar to Recipe 1, with three changes: no BitsAndBytesConfig (LoRA rather than QLoRA), device_map=None (FSDP manages placement), and per-device batch size reduced to 1 with accumulation steps increased to maintain an effective batch of approximately 32. Wall time for 50K examples over three epochs on 8× H100 is approximately 18 to 24 hours.

    FSDP2 / ZeRO-3: Sharding Across GPUs Naive Data Parallel (DDP) Each GPU holds full model + grads + optim state GPU 0 Params Grads Optim GPU 1 Params Grads Optim GPU 2 Params Grads Optim GPU 3 Params Grads Optim FSDP2 / ZeRO-3 Sharded Each GPU holds 1/N of each state GPU 0 P/4 G/4 O/4 GPU 1 P/4 G/4 O/4 GPU 2 P/4 G/4 O/4 GPU 3 P/4 G/4 O/4 Per-GPU memory: 70B model in BF16, 4 GPUs DDP (no sharding): ~560 GB/GPU (overflows 80GB H100 by 7×) ZeRO-2 (grads+optim): ~280 GB/GPU (still overflows) FSDP2 / ZeRO-3: ~140 GB/GPU (fits on H200, tight on H100) FSDP2 + 8× GPUs: ~70 GB/GPU (fits comfortably on H100)

    Recipe 3: Multi-Node Full Fine-Tune on GPT-OSS-120B

    Full fine-tuning a 117B MoE is genuinely expensive. The model weights in BF16 alone occupy approximately 234GB. With the addition of gradients, optimiser states (AdamW = twice the parameter count, in FP32 at 8 bytes each, approximately 940GB), and activations, cluster-class storage is required. The lower bound is 32 H100 GPUs across four nodes, using torchtitan with FSDP2 sharding across all 32 GPUs and tensor parallelism within each node.

    For most use cases this is not the appropriate path. Even with full fine-tuning, there is a risk of losing the post-training calibration and safety tuning baked into the released checkpoint. The pragmatic path for GPT-OSS-120B is LoRA with rank 32, with the adapter applied to attention and MoE expert gate projections only.

    Setup Combined VRAM What it can train
    Single H100 QLoRA 80 GB Up to ~70B with QLoRA; Qwen3.6-27B comfortably
    Single H200 QLoRA 141 GB Up to ~120B with QLoRA; comfortable 70B LoRA
    2× H200 LoRA 282 GB Full LoRA on Qwen3.5-122B-A10B with FSDP2
    8× H100 LoRA 640 GB LoRA on any model up to ~200B with sharding
    8× H100 full FT 640 GB Full FT up to ~70B with FSDP2 + activation checkpointing
    32× H100 multi-node 2,560 GB Full FT on 120B+ MoE; small pretraining runs

     

    Across all three recipes, the choice of optimiser matters more than is commonly appreciated. AdamW with a cosine learning rate schedule and 3 per cent warm-up is the strong default. For LoRA, the learning rate is typically 1e-4 to 2e-4—substantially higher than the 1e-5 to 5e-5 used for full fine-tuning—because LoRA’s adapter layers begin near zero and require larger steps to learn meaningful deltas. Checkpoints should be saved every 1000 steps. Adapter-only (PEFT) checkpoints are preferable to full-model checkpoints; they are approximately one hundred times smaller.

    For systematic optimisation of learning rate and rank, Bayesian hyperparameter optimisation with Gaussian processes is efficient. Random search is acceptable when the additional complexity is not warranted; grid search is almost never worthwhile for LoRA.

    Substantive Evaluation

    Most fine-tuning evaluation amounts to theatre. The model is trained, training loss decreases, an “evaluation” runs on a sliver of the training set (or the same data slightly shuffled), and the team declares success. The model is then deployed to production, where it underperforms.

    Substantive evaluation requires three properties: the evaluation data must not have been observed during training; the evaluation metric must measure the actual task rather than a proxy; and the metric must be reproducible across runs.

    For general language understanding and reasoning, the standard benchmarks are MMLU (multi-task language understanding across 57 subjects), HumanEval (function-completion code), GSM8K (grade-school mathematics word problems), and MT-Bench (multi-turn instruction following, judged by a strong LLM). For code-heavy use cases, SWE-bench Verified and Terminal-Bench 2.0 are the current standards.

    The community-standard tool is lm-evaluation-harness from EleutherAI, which runs the model against a registered benchmark suite in a reproducible manner:

    lm_eval \
      --model hf \
      --model_args pretrained=out/qwen36-27b-qlora,trust_remote_code=True \
      --tasks mmlu,gsm8k,humaneval \
      --batch_size auto \
      --output_path eval_results/qwen36-qlora.json
    

    The contamination problem is real and frequently neglected. If the training data was scraped from the public web, there is a non-trivial probability that benchmark questions are present. The decontamination check consists of an n-gram (typically 8-gram) overlap test between the training set and each benchmark’s question text, with any matches removed from training. Without this check, evaluation scores represent an upper bound that obscures the effect of contamination.

    Reading the Training Loss Curve 0 Training steps N Loss Healthy: monotonic decline eval loss tracks train loss Overfitting: eval rises while train keeps falling Loss spike → likely bad data batch Grad explosion → NaN lr too high, no clipping Diagnostic checklist — Healthy: smooth curve, eval ~= train — Overfit: stop early, more data, regularize — Spike: inspect batch at step N, dedup — Explosion: lower lr, add grad clipping Set grad_clip=1.0 as default; rerun from last good ckpt.

    Beyond standard benchmarks, a domain-specific evaluation set should be held out, constructed from realistic prompts drawn from the actual use case. Benchmark suites measure general capability; a custom evaluation set measures whether the model performs better at the relevant task. The two metrics frequently disagree, and the custom set is the one that ultimately matters.

    Tip: Construct the held-out evaluation set before fine-tuning begins, and store it at a separate file path that the training code cannot access. The temptation to inspect and “improve” the evaluation set after a poor run is a silent destroyer of meaningful evaluation.

    Deployment

    When training is complete, the adapter or full checkpoint resides in a directory and must be served.

    The two standard serving stacks in 2026 are vLLM and SGLang. vLLM has the broadest support and is the production default for most teams. SGLang is faster for structured-output workloads (JSON, regex-constrained generation) and provides superior RadixAttention KV-cache reuse for repeated-prefix workloads such as RAG and multi-turn chat.

    Both implement continuous batching, a serving technique that keeps the GPU saturated by dynamically inserting new requests into the batch as existing requests complete, rather than waiting for the whole batch to finish. The throughput multiplier of continuous batching over static batching is typically a factor of three to five, sometimes more.

    Deployment Serving Stack Checkpoint PEFT adapter + base model Quantize AWQ / GPTQ / MXFP4 / FP8 vLLM / SGLang continuous batching KV cache PagedAttention block-based Clients OpenAI- compatible Throughput multiplier from continuous batching (vs static batching, same GPU) Static: 1× Continuous batching: ~3× + PagedAttention + prefix cache: ~5× or more on RAG workloads Measured tokens/second for concurrent 256-request streams Quantization trade-offs at inference time FP16 / BF16: baseline quality, 2× VRAM of int4 AWQ (Activation-aware Weight Quant): 4-bit, ~0.5pp quality loss, fast kernels in vLLM GPTQ: 4-bit, post-training, slightly lower quality than AWQ but broader compatibility MXFP4: 4-bit FP w/ block scale; GPT-OSS-120B trained with it; cleanest precision/cost trade

    For a fine-tuned Qwen3.6-27B served on a single H100, the launch command is as follows:

    vllm serve out/qwen36-27b-qlora \
      --host 0.0.0.0 \
      --port 8000 \
      --max-model-len 32768 \
      --dtype bfloat16 \
      --enable-lora \
      --lora-modules my-adapter=out/qwen36-27b-qlora \
      --gpu-memory-utilization 0.92 \
      --enable-prefix-caching \
      --tensor-parallel-size 1
    

    The serving endpoint exposes an OpenAI-compatible API at http://localhost:8000/v1. On the client side, it functions as a direct substitute for the OpenAI SDK:

    from openai import OpenAI
    
    client = OpenAI(
        base_url="http://localhost:8000/v1",
        api_key="EMPTY",  # vLLM ignores the key by default
    )
    
    response = client.chat.completions.create(
        model="my-adapter",
        messages=[
            {"role": "system", "content": "You are an industrial controls expert."},
            {"role": "user",   "content": "What causes oscillation after a payload change on a cobot joint?"},
        ],
        temperature=0.2,
        max_tokens=512,
    )
    
    print(response.choices[0].message.content)
    

    If the deployment forms part of a larger application, the serving pods may be run on Kubernetes with a GPU-aware scheduler. For tool-augmented workflows, tool calling support in vLLM via Hermes-style JSON output operates by default for Qwen3.6 and GPT-OSS. For broader integrations, the Model Context Protocol (MCP) is emerging as the de facto integration standard for tool-using LLM applications.

    Common Pitfalls and Debugging

    Most training failures derive from a small set of recurring mistakes. Awareness of these in advance saves substantial debugging time.

    Chat template mismatch. Previously noted, but worth repeating because it is the most common silent failure. The training-time template and the inference-time template must be identical. A fully tokenised example with special tokens visible (tokenizer.decode(input_ids, skip_special_tokens=False)) should be printed before beginning any long run.

    Out-of-memory mid-training. The loss curve appears acceptable for 5,000 steps, after which a single long sequence in a batch exceeds the activation memory budget. The remedy is to lower max_seq_length, enable packing=True with a sequence cap, or reduce per-device batch size and increase gradient accumulation to compensate.

    Tokenizer drift. The base model has been loaded with one tokenizer revision and inference performed with another, causing the vocabulary or special-token IDs to shift. The tokenizer commit hash should be locked explicitly: AutoTokenizer.from_pretrained(MODEL_ID, revision="abc123def...").

    Loss spikes. A large upward jump in loss at a specific step almost always indicates a bad batch—corrupted data, a tokenisation error on a single example, or an unusually long sequence. The data at that step should be inspected. If recurrence is rare, gradient clipping (max_grad_norm=1.0) should be added and training resumed from the last good checkpoint.

    Evaluation/training distribution mismatch. Training loss is low, while evaluation loss is high and fails to improve. The evaluation set is drawn from a different distribution from the training set. Either the evaluation set should be drawn from the same source as the training data (with a fresh seed split), or the gap should be accepted as a measure of generalisation rather than a training failure.

    Gradient explosion. Loss diverges to NaN within a few steps. The learning rate is too high for the task, gradient clipping has been omitted, or the data contain an extreme outlier in numerical features. Training should restart with learning_rate halved and max_grad_norm=1.0.

    MoE-specific: expert collapse. Specific to MoE training (Qwen3.5-122B, GPT-OSS-120B). The router learns to route everything to one or two experts, and the remainder of the model atrophies. The mitigation is an auxiliary load-balancing loss, which TRL and torchtitan include by default; this should nonetheless be verified as enabled rather than silently overridden by a configuration setting.

    Caution: Training should always be launched with W&B (or an equivalent) logging enabled, and the loss curve should be reviewed every few hundred steps. Detecting a failure in the first hour costs an hour; detecting it at the twelve-hour evaluation costs a day and the cloud bill.

    FAQ

    Can these models be fine-tuned on a consumer GPU such as an RTX 4090?

    Qwen3.6-27B can be fine-tuned on a 4090 with QLoRA. The 24GB of VRAM on a 4090 is tight but workable with gradient checkpointing, a paged 8-bit optimiser, and a short sequence length (approximately 2048 tokens). Qwen3.5-122B-A10B and GPT-OSS-120B require at least 80GB of VRAM, which corresponds to H100/H200/MI300X-class hardware. The released GPT-OSS-120B can be served (though not trained) on a single 80GB card due to MXFP4 quantisation.

    How much data is actually required?

    Less than is commonly expected. For domain adaptation with LoRA or QLoRA, 5,000 to 20,000 high-quality examples are sufficient for most domains. Quality matters considerably more than quantity: a tightly curated 10,000-example set consistently outperforms a noisy 100,000-example set. For format adaptation (teaching the model a new structured output schema), 1,000 to 2,000 examples often suffice.

    How does this compare with using a managed API?

    The two represent different problem spaces. Managed APIs (OpenAI, Anthropic) excel in convenience and access to the latest models. Self-hosted fine-tuned models excel in cost per million tokens at scale, data sovereignty, custom domain adaptation, and predictable cost (no per-call billing). The crossover point is typically around 100M tokens per month; below this, managed services are usually preferable, and above it, self-hosted is usually cheaper.

    What is the quality difference between LoRA and full fine-tuning?

    LoRA retains 90 to 95 per cent of full fine-tuning quality across most tasks. QLoRA retains 80 to 90 per cent. The remaining gap is largest on tasks requiring substantial representational shift from the base model—for example, fine-tuning an English-pretrained model to operate fluently in a low-resource language. For typical instruction tuning, code adaptation, or structured-output tasks, the gap is sufficiently small that the cost savings of LoRA dominate.

    Should continued pretraining precede instruction tuning?

    Only when the domain is genuinely far from the base model’s training distribution—medical literature, legal contracts in a non-English language, or highly specialised scientific notation. For most domains, the base model has sufficient coverage that instruction tuning alone closes the gap. Continued pretraining is expensive and easily mishandled, with the principal risk being catastrophic forgetting of the base model’s general competence.

    References

    Conclusion

    Training open-source LLMs in 2026 is no longer the closed activity it was two years ago. The combination of Apache 2.0 base models with frontier-class reasoning (GPT-OSS-120B approaching o4-mini), QLoRA on a single rented GPU, and serving infrastructure capable of handling thousands of concurrent users on commodity hardware has placed production-grade LLM customisation within reach of any team with a modest budget and a clear use case.

    The three anchor models cover the practical range: Qwen3.6-27B for the single-GPU dense workflow, Qwen3.5-122B-A10B for inexpensive MoE serving when multi-GPU capacity is available, and GPT-OSS-120B for single-GPU serving of a frontier-class reasoner enabled by MXFP4. None of these is universally “best”; each addresses different questions about hardware, latency, and quality.

    The principal challenge is no longer the technology; it is the data—assembling, deduplicating, formatting, and contamination-checking a dataset that actually teaches the model the intended behaviour. The trainer runs in eight hours. The dataset takes eight weeks. Planning should be adjusted accordingly.

  • Kubernetes Pods Explained: Why Connecting to a Database Pod Is Hard

    This article examines the architecture of Kubernetes pods and explains why directly connecting an external client to a database running inside a pod is more involved than the equivalent task with a standalone Docker container. The discussion is grounded in the networking model that Kubernetes uses and in the Service abstraction that the model requires. A common experience for engineers new to Kubernetes is that kubectl exec into a Postgres pod followed by psql -h localhost works as expected, while a parallel attempt from a developer laptop, using the pod IP reported by kubectl get pods -o wide, times out with no error. The credentials, the database, and the apparent network are the same, yet the second connection never completes. This outcome is not the result of a defect; it is a direct consequence of how the platform is designed, and understanding the design is the first step toward connecting to in-cluster databases in a reliable manner.

    Summary

    What this post covers: A practical, code-first explanation of Kubernetes pods, the flat-IP networking model that makes the cluster tick, and the specific reasons that connecting a database container to clients outside its pod is harder than running docker run -p 5432:5432 postgres.

    Key insights:

    • Pod IPs are ephemeral; the moment a pod restarts, the address you memorized is gone, which is why hard-coded connection strings break in ways that look like network failures.
    • ClusterIP — the default Service type — only exists inside the cluster, so the IP that kubectl get svc shows you is unreachable from a laptop without explicit forwarding.
    • Stateful workloads like Postgres need StatefulSets and PersistentVolumeClaims, not plain Deployments, or you will lose data the first time a pod reschedules to another node.
    • kubectl port-forward is wonderful for local development and dangerous in production — it tunnels through the API server and bypasses normal auth and network policies.
    • Kubernetes 1.36, released in April 2026, promoted User Namespaces, Mutating Admission Policies, and Fine-Grained Kubelet API Authorization to GA, all of which tighten the security defaults that govern who can talk to what inside a cluster.

    Main topics: Why Kubernetes Exists in the First Place, The Pod: Smaller Than a VM, Bigger Than a Container, The Flat Networking Model That Nobody Warns You About, Services: How Pods Actually Find Each Other, Why Connecting Directly to a Database Pod Falls Apart, Three Connection Patterns That Actually Work, Kubernetes 1.36 and What Changed in 2026

    Why Kubernetes Exists in the First Place

    Docker addressed a genuine packaging problem by allowing an application and its dependencies to be shipped as a single reproducible image that behaves consistently on a developer laptop, a continuous integration runner, and a production virtual machine. Readers who have not yet worked through the container model will find the Docker containers, from dev to production guide a useful prerequisite for the material that follows. Once an organization operates several dozen containers distributed across several dozen servers, however, Docker alone becomes insufficient. Several questions arise that the single-host model does not answer: which host should run a given container, what should happen if that host fails outside business hours, how can a new version be rolled out without dropping in-flight requests, how do containers on one host discover containers on another, and how should compute and memory budgets be enforced.

    Kubernetes is the answer that became the industry consensus. It originated inside Google as a re-implementation of the Borg system and was open-sourced in 2014. Kubernetes is best understood as a cluster operating system: the operator declares the desired state of the workload, and a chain of controllers continuously reconciles the actual state of the cluster with that declaration. The unit that Kubernetes manages is not a container directly but a pod, a wrapper around one or more tightly coupled containers that share a network identity and storage. All higher-level objects, including Deployments, Services, StatefulSets, Jobs, and CronJobs, are abstractions that ultimately specify which pods should exist, where they should run, and how they should be exposed.

    Kubernetes Cluster Architecture Control Plane (master) kube-apiserver REST front door auth + admission talks to everything etcd key/value store all cluster state single source of truth scheduler picks node for pod resources, taints, affinity rules controller-manager replicaset, deployment, node, endpoint, job, reconciliation loops Worker node 1 kubelet | runtime | kube-proxy pod: api 10.244.1.5 pod: worker 10.244.1.6 pod: cache 10.244.1.7 Worker node 2 kubelet | runtime | kube-proxy pod: api 10.244.2.5 pod: ingest 10.244.2.6 pod: postgres-0 10.244.2.7 (StatefulSet) Worker node 3 kubelet | runtime | kube-proxy pod: web 10.244.3.5 pod: cron 10.244.3.6 pod: postgres-1 10.244.3.7 (replica) All node-to-control-plane traffic is mediated by the API server. Pods talk to each other directly through the CNI overlay.

    The control plane performs the coordination function, while worker nodes execute the workload. Each worker runs three components in its base layer. The kubelet is the agent that receives instructions from the API server and applies them to the local node. The container runtime, which is now almost always containerd or CRI-O, executes the containers themselves; Docker as a runtime was deprecated in version 1.20 and removed in version 1.24. The kube-proxy process programs the kernel iptables or IPVS rules that route Service IPs to actual pod endpoints. Pods are scheduled on top of these three components.

    The Pod: Smaller Than a VM, Bigger Than a Container

    A pod is the smallest deployable unit in Kubernetes. The simplest pod runs a single container, but the abstraction exists precisely because the smallest unit that an engineer sometimes needs to ship is more than one container. A common configuration places a primary application container alongside a sidecar container that handles logging, TLS termination, metric scraping, or database proxying. All containers in the same pod share a single network namespace, which means they can communicate over localhost, and they can share filesystem volumes. They are always scheduled together onto the same node, and their lifecycles are linked: they are created together, restarted together, and terminated together.

    Anatomy of a Pod Pod: api-789f-bc4 — one IP, one DNS name, one lifecycle Container: app FastAPI on:8000 image: app:1.4.2 talks to sidecar over localhost:6432 writes logs to /var/log/app Sidecar: pgbouncer connection pool listens on:6432 forwards to postgres.db.svc:5432 shares network namespace with app Sidecar: log-tail vector / fluent-bit image: log-agent:3.0 reads /var/log/app via shared volume ships to Loki over Service DNS Shared network namespace — same 10.244.2.5, same loopback Containers reach each other on localhost. Outside the pod, they all appear as one IP. Shared volumes emptyDir /var/log/app (in-memory) and configMap /etc/app/config mounted into all three

    The manifest below shows the minimum specification for a pod. Three details are worth noting. The apiVersion: v1 field indicates that pods are part of the core Kubernetes API. The specification contains a single container running an Nginx image. There is no top-level restart policy, which reflects the fact that bare pods are not self-healing: when a node fails, the pod fails with it. Bare pods are therefore rarely used in production. They are useful primarily for one-off tests and as a teaching device.

    # pod.yaml — the simplest possible pod
    apiVersion: v1
    kind: Pod
    metadata:
      name: hello-pod
      labels:
        app: hello
    spec:
      containers:
      - name: web
        image: nginx:1.27
        ports:
        - containerPort: 80
        resources:
          requests:
            cpu: "50m"
            memory: "64Mi"
          limits:
            cpu: "200m"
            memory: "128Mi"
    

    The object that an operator actually deploys is a Deployment, a controller that maintains a desired number of identical pods, handles rolling updates, and recreates pods when nodes fail. The Deployment owns a ReplicaSet, which in turn owns the pods. Operators rarely reference pods directly. Instead, they reference the Deployment, and Kubernetes manages the underlying pods.

    # deployment.yaml — a real workload
    apiVersion: apps/v1
    kind: Deployment
    metadata:
      name: api
      labels:
        app: api
    spec:
      replicas: 3
      selector:
        matchLabels:
          app: api
      strategy:
        type: RollingUpdate
        rollingUpdate:
          maxSurge: 1
          maxUnavailable: 0
      template:
        metadata:
          labels:
            app: api
        spec:
          containers:
          - name: api
            image: ghcr.io/acme/api:1.4.2
            ports:
            - containerPort: 8000
            readinessProbe:
              httpGet: { path: /healthz, port: 8000 }
              initialDelaySeconds: 5
            livenessProbe:
              httpGet: { path: /livez, port: 8000 }
              initialDelaySeconds: 15
            env:
            - name: DB_HOST
              value: "postgres.db.svc.cluster.local"
            - name: DB_PORT
              value: "5432"
    
    Tip: Always set both requests, which are the resources the scheduler reserves for the pod, and limits, which are the ceilings the kernel enforces. Without requests, the scheduler treats the pod as requiring no resources and may place it on an already saturated node. Without limits, a runaway process can starve every other workload on the host.

    The Flat Networking Model and Its Implications

    The Kubernetes networking model rests on four rules that appear simple on the surface but carry substantial implications for how traffic flows inside a cluster:

    1. Every pod gets its own IP address, drawn from a cluster-wide CIDR range that does not overlap with the node IPs.
    2. Pods on the same node can communicate without NAT.
    3. Pods on different nodes can communicate without NAT.
    4. The IP a pod sees as its own is the same IP that other pods see when they talk to it.

    The fourth point carries the most weight. In a typical single-host Docker configuration, a container holds a private IP on a bridge network, and outbound traffic is translated through the host using NAT. Kubernetes deliberately avoids this arrangement. Every pod is a first-class participant in a single flat network, regardless of the physical machine that hosts it. The component that implements this property is a CNI plugin (Container Network Interface), of which several mature implementations exist, including Calico, Cilium, Flannel, Weave, the AWS VPC CNI, and the native plugin used by GKE. These plugins implement the same contract but differ in their mechanisms, which range from overlays based on VXLAN tunnels, to BGP route advertisement, to native cloud routing, to eBPF-based data planes.

    Flat Pod-to-Pod Networking (no NAT, one CIDR) Node A — 192.168.10.11 pod-a1 10.244.1.5 pod-a2 10.244.1.6 CNI plugin (Calico/Cilium) veth + bridge + routing programs kernel routes Node B — 192.168.10.12 pod-b1 10.244.2.5 pod-b2 10.244.2.6 CNI plugin VXLAN/IPIP/BGP/native tunnel or route exchange Node C — 192.168.10.13 postgres-0 10.244.3.7 pod-c2 10.244.3.5 CNI plugin programs route to other nodes 10.244.0.0/16 known Underlay network — physical/virtual switching between nodes 192.168.10.0/24 (node CIDR). Pod traffic encapsulated or routed natively across this. pod-a1 talking to postgres-0 sees: src=10.244.1.5 dst=10.244.3.7 No SNAT. No DNAT. postgres-0 sees the real source IP of pod-a1. This is the property that makes mTLS, audit logs, and network policies meaningful.

    This flat addressing scheme is convenient for application code, which simply connects to an IP address and proceeds, but it is demanding for operators who must reason about traffic flows. Every pod is mutually addressable inside the cluster, which means that in the absence of explicit policies, every pod is able to reach every database, every cache, and every internal API. This property becomes important in a later section, which explains why directly addressing a database pod is more fragile than it appears.

    Services: How Pods Actually Find Each Other

    Because pod IPs change on every restart, they cannot appear in a connection string. The Kubernetes solution to this problem is a Service, a stable virtual IP and DNS name that load-balances traffic to a set of pods identified by labels. Individual pods come and go, but the Service remains. Inside the cluster, every Service automatically receives a DNS name of the form service-name.namespace.svc.cluster.local, which is resolved by CoreDNS, the built-in DNS resolver of the cluster.

    Service Types — Where Each One Is Reachable From ClusterIP (default) Scope cluster-internal only virtual IP from Service CIDR Reachable from other pods (yes) node terminal (yes) laptop (no) internet (no) Use for internal APIs, databases, caches, message brokers NodePort (simple external) Scope every node IP + high port 30000-32767 Reachable from other pods (yes) laptop on VPC (yes) internet (if firewall) but ugly ports Use for on-prem clusters without an LB, debugging, bare-metal demos LoadBalancer (cloud LB) Scope public IP from cloud provider (NLB/ALB/CLB) Reachable from internet (yes) any TCP/UDP port L4 load balancing ~$15-25/mo per LB Use for non-HTTP services (gRPC, raw TCP) one LB per Service externalTrafficPolicy Ingress (L7 HTTP) Scope path/host-based routing on:80/:443 single LB for many Reachable from internet (yes) HTTP/HTTPS only TLS terminated not for Postgres Use for web APIs, microservices, SaaS multi-tenant cert-manager + TLS

    Service type Scope Typical use Port range Downside
    ClusterIP Inside cluster only Databases, caches, internal APIs Any (virtual) Unreachable from outside without help
    NodePort Every node’s IP + high port On-prem clusters, debugging 30000–32767 Ugly URLs, every node exposes it
    LoadBalancer Public IP from cloud LB Non-HTTP services to internet Any TCP/UDP Costs money, one LB per Service
    Ingress L7 HTTP/HTTPS routing Web apps, REST/gRPC over HTTP 80, 443 HTTP only — will not route Postgres

     

    How a Pod Finds a Service — Step by Step Client pod api-789-bc4 10.244.1.5 DB_HOST = postgres.db.svc 1. DNS query CoreDNS cluster DNS resolver runs as pod in kube-system 10.96.0.10 (kube-dns) 2. returns ClusterIP Service: postgres type: ClusterIP 10.96.42.7:5432 virtual IP — no host 3. TCP to 10.96.42.7:5432 kube-proxy on each node — iptables / IPVS / nftables rules Watches Services + EndpointSlices. Rewrites destination IP from the virtual ClusterIP to an actual pod IP (round-robin or session-affinity) — entirely in the kernel. No userspace hop. The packet never visits a proxy process. 4. DNAT to a pod postgres-0 10.244.3.7:5432 label: app=postgres postgres-1 10.244.4.7:5432 label: app=postgres postgres-2 10.244.5.7:5432 label: app=postgres EndpointSlice objects keep this set up to date as pods come and go.

    The manifest below defines a ClusterIP Service for a Postgres pod. The selector field is worth attention, because the Service matches by labels rather than by name. Any pod that carries the label app: postgres in the same namespace automatically becomes a backend.

    # service.yaml — ClusterIP for Postgres
    apiVersion: v1
    kind: Service
    metadata:
      name: postgres
      namespace: db
    spec:
      type: ClusterIP            # default, can omit
      selector:
        app: postgres
      ports:
      - name: pg
        port: 5432              # the Service port
        targetPort: 5432        # the container port
        protocol: TCP
    

    From any other pod inside the cluster, the command psql -h postgres.db.svc.cluster.local -p 5432 will succeed. The same command issued from a developer laptop will hang indefinitely. The next section examines the reasons for this gap in detail.

    Why Direct Connections to a Database Pod Fail

    The assumption that breaks down for engineers coming from a plain Docker workflow is the idea that a container can be reached as long as one knows its IP address and port. In Kubernetes, almost every element of that assumption is incorrect. The pod IP is real but private, ephemeral, and exists only on a network shared between the nodes of a single cluster. The container port is open inside the container but is not automatically exposed at any higher layer. There is no host-level port-publishing equivalent to docker run -p 5432:5432; the field hostPort exists but is discouraged for production use. The following paragraphs examine each failure mode in turn.

    Why “psql -h 10.244.3.7” From Your Laptop Hangs Your laptop 192.168.0.42 “psql -h 10.244.3.7” no route, no return SYN sent into the void timeout Internet / Cloud VPC routes only to node IPs 10.244.0.0/16 is NOT advertised externally drops the packet Cluster boundary Even if you reached a node, kube-proxy would not DNAT for an arbitrary pod IP. There is no Service entry for the raw IP. no rule matches → packet rejected Inside the cluster (other pods) 10.244.3.7 IS reachable — until postgres-0 restarts and becomes 10.244.3.18. A connection string pinned to 10.244.3.7 fails the next deploy. Hence: never use pod IPs. Five hidden failure modes most people hit before giving up 1. Pod IP changed because the pod restarted → old IP belongs to nothing now. 2. ClusterIP Service exists but you are connecting from outside the cluster → no external route. 3. NetworkPolicy denies all ingress to db namespace by default → even valid traffic dropped. 4. Postgres bound to 127.0.0.1 inside container → listening but not on the pod IP. 5. pg_hba.conf rejects the source CIDR → TCP handshake succeeds, auth fails silently. 6. Cloud security group blocks the node port even when NodePort is configured correctly.

    Pod IPs are ephemeral. The moment a pod restarts, for any reason ranging from a node reboot, to a failed liveness probe, to a manual kubectl rollout restart, to an eviction by the scheduler, the new pod receives a new IP address from the address pool that the CNI manages. Any client that retains a reference to the previous IP is now communicating with nothing, or in the worst case, with whatever pod has been allocated the recycled address. This is the reason pod IPs should never be written into a configuration file. The correct address to record is a Service DNS name, which CoreDNS resolves at lookup time.

    The ClusterIP is not visible from outside the cluster. The Service IP that kubectl get svc reports, such as 10.96.42.7 in the earlier example, is a virtual IP. It does not belong to any physical or virtual network interface and exists only as an entry in the iptables tables that kube-proxy maintains on each node. A laptop outside the cluster has no route to the Service CIDR 10.96.0.0/12, and even a statically added route would not help, because no kernel outside the cluster contains the rules required to translate that virtual address.

    Pods do not use the host network by default. Setting hostNetwork: true on a pod causes the container to share the network namespace of the node, with the consequence that the container port maps directly to a port on the node. This configuration is used by CNI agents, node-exporter, and similar infrastructure components. Applying it to a database, however, is poor practice: IP isolation is lost, port collisions become possible, and any node failure takes the database with it, since the address is tied to a specific host and cannot be moved.

    NetworkPolicies can explicitly deny traffic. When the cluster runs a CNI that supports NetworkPolicy, which most modern plugins do, operators can write rules such as “only pods labeled role: api in the app namespace may connect to pods labeled app: postgres in the db namespace on port 5432.” When a default-deny baseline is in place and no allow rule has been written, all traffic is dropped. When no policies are present at all, all traffic is permitted, which presents its own security concerns.

    # networkpolicy.yaml — only the api can talk to postgres
    apiVersion: networking.k8s.io/v1
    kind: NetworkPolicy
    metadata:
      name: postgres-allow-api
      namespace: db
    spec:
      podSelector:
        matchLabels:
          app: postgres
      policyTypes:
      - Ingress
      ingress:
      - from:
        - namespaceSelector:
            matchLabels:
              name: app
          podSelector:
            matchLabels:
              role: api
        ports:
        - protocol: TCP
          port: 5432
    

    The container port is not automatically exposed at the node level. Docker users are accustomed to -p 5432:5432, which binds a host port to a container port. Kubernetes provides no equivalent automatic mapping. The containerPort field in a pod specification is documentation: it informs operators and tooling that the container intends to listen on the indicated port, but it does not open a path through any higher layer. External reachability requires a Service of the appropriate type and, in cloud environments, a security group rule that permits traffic to whichever node port the cloud load balancer or NodePort uses.

    Databases are stateful, and stateful pods require stateful controllers. A plain Deployment treats its pods as interchangeable replicas. The Deployment will reschedule postgres-0 from node 2 to node 5 when node 2 becomes unhealthy, mounting whichever PersistentVolume is available, or no volume at all if the PersistentVolumeClaim has been deleted. A database instead requires a StatefulSet, which assigns each pod a stable identity such as postgres-0 or postgres-1, a stable per-pod DNS name served by a headless Service, and a stable PersistentVolumeClaim that remains attached to the same ordinal across reschedules. A misconfiguration in this area is a common cause of data loss for teams new to running databases on Kubernetes.

    The request path is long, and any single weak link breaks it. When an external client reaches a pod, the request typically traverses the following sequence: client, public DNS, cloud load balancer, node IP, iptables DNAT, pod IP, container port, Postgres listener, pg_hba.conf check, and finally authentication. A misconfiguration at any stage, such as an incorrect TLS certificate, a security group blocking the load balancer health check, a pg_hba.conf rule that denies the source CIDR, or a Postgres listener bound to 127.0.0.1 inside the container rather than 0.0.0.0, produces a connection failure that appears identical to a network problem from the perspective of the client.

    Failure mode Symptom Root cause Proper workaround
    Pod IP in connection string Works for hours, then suddenly times out after a restart CNI re-allocated IP to a different pod Use Service DNS name (postgres.db.svc.cluster.local)
    Laptop connecting to ClusterIP TCP timeout, no error No route from laptop to Service CIDR Use kubectl port-forward or a bastion
    Default-deny NetworkPolicy Within-cluster traffic also dropped No explicit allow rule for the source Write a targeted ingress NetworkPolicy
    Postgres bound to 127.0.0.1 Connection refused even inside cluster listen_addresses not set to * Fix postgresql.conf in the image/ConfigMap
    Pod rescheduled, lost data Tables empty after a node failure Deployment used instead of StatefulSet, no PVC StatefulSet + PVC + headless Service
    pg_hba.conf rejects source “no pg_hba.conf entry for host” error Pod CIDR not allowed Add cluster pod CIDR to pg_hba.conf
    LoadBalancer reachable but SG blocks Timeout from internet Cloud security group does not allow 5432 Open SG to client IPs, lock to known sources

     

    Caution: Operators tempted to expose a production database to the public internet through a LoadBalancer should reconsider whether such exposure is necessary. The preferred design is to keep the database internal to the cluster and to route application traffic through a hardened API tier. An internet-facing Postgres listener on port 5432 is among the most heavily attacked surfaces on the public internet.

    Three Reliable Connection Patterns

    Three legitimate patterns exist for connecting a client to a database that runs in a pod, and the appropriate choice depends primarily on the location of the client. Selecting among them is largely a question of which client requires the connection and for how long.

    Three Patterns — Pick the One That Matches Your Client A — In-cluster app ClusterIP + DNS app pod DB_HOST=postgres.db.svc CoreDNS → ClusterIP 10.96.42.7:5432 postgres-0 pod 10.244.3.7:5432 Best for production app traffic, CronJobs, Airflow DAGs, message workers B — Local developer kubectl port-forward laptop psql connects to localhost:5432 kubectl port-forward SPDY tunnel via API server postgres-0 pod (direct) no Service involved Best for debugging, migrations, one-off admin queries. NEVER for prod traffic. C — External app LoadBalancer + TLS + auth external app postgres.example.com:5432 cloud LB (NLB) SG: allow client CIDR postgres pod (via Service) TLS + strong auth required Best for analytics replica only, otherwise route through an API tier instead.

    Pattern A: In-cluster application to in-cluster database

    This pattern is the default and the most reliable choice. The application pod sets DB_HOST=postgres.db.svc.cluster.local as an environment variable and opens a connection. CoreDNS resolves the name, kube-proxy translates the virtual IP into the address of a real pod through DNAT, and the connection succeeds. Pod restarts on either side remain transparent because every endpoint is named rather than pinned to a specific IP. This is also the pattern that Airflow workloads adopt when they run with the KubernetesExecutor described in the Apache Airflow data pipeline orchestration guide, in which each task is launched as a pod that reaches the database through a Service. The same pattern applies to dbt jobs running on Kubernetes and to Kafka consumer workloads running in pods.

    Pattern B: Local developer to in-cluster database

    The command kubectl port-forward opens a tunnel from a local port on a developer machine, through the Kubernetes API server, to a port on a pod. It is intended for development and one-off administrative tasks. The example below uses it against the headless Service that the next subsection defines:

    # forward localhost:5432 to the postgres-0 pod's port 5432
    kubectl port-forward -n db pod/postgres-0 5432:5432
    
    # Or forward through the headless Service to whichever endpoint is selected
    kubectl port-forward -n db svc/postgres 5432:5432
    
    # Now from another terminal, on your laptop:
    psql -h localhost -p 5432 -U app -d production
    

    The Python client below connects through the forwarded port. The connection string specifies localhost, which is correct on the developer laptop. Inside the cluster, the same code would instead specify postgres.db.svc.cluster.local.

    # dev_query.py — assumes "kubectl port-forward" is running
    import os
    import psycopg2
    from psycopg2.extras import RealDictCursor
    
    # Local dev: connect through kubectl port-forward
    # In production (in-cluster), DB_HOST would be postgres.db.svc.cluster.local
    DB_HOST = os.environ.get("DB_HOST", "localhost")
    DB_PORT = int(os.environ.get("DB_PORT", "5432"))
    DB_NAME = os.environ.get("DB_NAME", "production")
    DB_USER = os.environ.get("DB_USER", "app")
    DB_PASS = os.environ["DB_PASS"]  # required, no default
    
    def fetch_recent_orders(limit: int = 50):
        """Read the most recent orders — example dev-time query."""
        with psycopg2.connect(
            host=DB_HOST,
            port=DB_PORT,
            dbname=DB_NAME,
            user=DB_USER,
            password=DB_PASS,
            connect_timeout=5,
            sslmode="require",   # still enforce TLS even on port-forward
        ) as conn:
            with conn.cursor(cursor_factory=RealDictCursor) as cur:
                cur.execute(
                    "SELECT id, customer_id, total_cents, created_at "
                    "FROM orders ORDER BY created_at DESC LIMIT %s",
                    (limit,),
                )
                return cur.fetchall()
    
    if __name__ == "__main__":
        rows = fetch_recent_orders()
        for row in rows:
            print(row)
    
    Caution: kubectl port-forward bypasses NetworkPolicies because the tunnel travels through the kubelet rather than as pod-to-pod traffic. Any user who holds pods/portforward RBAC permission on the namespace can reach the database, regardless of the NetworkPolicy configuration. The verb should therefore be treated as a form of production database access and subjected to audit logging.

    Pattern C: External application to in-cluster database

    This is the pattern about which most teams should hesitate. When an application outside the cluster needs to read from or write to the database, the preferred architecture is almost always to expose an API over HTTP or gRPC through an Ingress with TLS and authentication, and to let the API mediate access to the database. Legitimate cases for direct external access nevertheless exist, including analytics tools, business intelligence dashboards, and replication to external systems. In those cases the pattern takes the following shape: a Service of type LoadBalancer backed by the database pods, fronted by a cloud network load balancer, with the security group restricted to specific client CIDRs, mandatory TLS, and a credential rotation policy. When a managed database such as Amazon RDS, Google Cloud SQL, or Aurora can be substituted, that option is usually preferable. Operating Postgres inside Kubernetes is technically feasible, but it represents a significant operational commitment.

    The StatefulSet plus headless Service pattern

    StatefulSet + Headless Service for a Database Headless Service — ClusterIP: None postgres.db.svc.cluster.local resolves to ALL pod IPs (DNS A records, one per pod) Plus per-pod names: postgres-0.postgres.db.svc, postgres-1.postgres.db.svc, postgres-2.postgres.db.svc postgres-0 (primary) postgres-0.postgres.db.svc postgres container image: postgres:16.3 role: primary accepts writes PVC: data-postgres-0 storageClass: gp3-ssd size: 200 GiB accessMode: RWO stays with postgres-0 postgres-1 (replica) postgres-1.postgres.db.svc postgres container image: postgres:16.3 role: replica (streaming) read-only PVC: data-postgres-1 independent volume full replica copy stays with postgres-1 survives reschedule postgres-2 (replica) postgres-2.postgres.db.svc postgres container image: postgres:16.3 role: replica (streaming) read-only PVC: data-postgres-2 independent volume full replica copy stays with postgres-2 stable identity Writes go to postgres-0.postgres.db.svc. Reads can fan out to all three. Identity survives reschedule.

    A headless Service is the object produced when clusterIP: None is set in the specification. Rather than allocating a virtual IP, this configuration produces DNS A records, with one record per pod backend. When combined with a StatefulSet, the result is a set of stable per-pod hostnames, such as postgres-0.postgres.db.svc.cluster.local and postgres-1.postgres.db.svc.cluster.local. This naming arrangement is precisely what a primary-replica database deployment requires. The application directs writes to the hostname of the primary and reads to the hostname of any replica.

    # headless service + statefulset for postgres
    apiVersion: v1
    kind: Service
    metadata:
      name: postgres
      namespace: db
      labels:
        app: postgres
    spec:
      clusterIP: None          # headless — no virtual IP
      selector:
        app: postgres
      ports:
      - name: pg
        port: 5432
        targetPort: 5432
    ---
    apiVersion: apps/v1
    kind: StatefulSet
    metadata:
      name: postgres
      namespace: db
    spec:
      serviceName: postgres    # MUST match the headless Service name
      replicas: 3
      selector:
        matchLabels:
          app: postgres
      template:
        metadata:
          labels:
            app: postgres
        spec:
          terminationGracePeriodSeconds: 30
          containers:
          - name: postgres
            image: postgres:16.3
            ports:
            - containerPort: 5432
              name: pg
            env:
            - name: POSTGRES_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: postgres-secret
                  key: password
            - name: PGDATA
              value: /var/lib/postgresql/data/pgdata
            volumeMounts:
            - name: data
              mountPath: /var/lib/postgresql/data
            readinessProbe:
              exec:
                command: ["pg_isready", "-U", "postgres"]
              initialDelaySeconds: 10
              periodSeconds: 5
            resources:
              requests:
                cpu: "500m"
                memory: "1Gi"
              limits:
                cpu: "2"
                memory: "4Gi"
      volumeClaimTemplates:
      - metadata:
          name: data
        spec:
          accessModes: ["ReadWriteOnce"]
          storageClassName: gp3-ssd
          resources:
            requests:
              storage: 200Gi
    

    Production databases almost always benefit from a purpose-built operator layered on top of this scaffolding, such as CloudNativePG, the postgres-operator developed by Zalando, or Crunchy PGO. These operators handle primary election, streaming replication, backups, point-in-time recovery, and rolling minor-version upgrades. Selecting an appropriate database backend is a separate concern; the database comparison for preprocessed time-series data serves as a useful companion reference for that decision.

    Key Takeaway: Pod IPs are an internal implementation detail of the cluster and should never serve as the target of a client connection. Inside the cluster, use Service DNS names. From a developer laptop, use kubectl port-forward. For external clients, use a managed load balancer, or preferably an API tier placed in front of the database. Stateful workloads should always combine a StatefulSet, a PersistentVolumeClaim, and a headless Service.

    Kubernetes 1.36 and What Changed in 2026

    Kubernetes 1.36 is the most recent minor release as of this writing in May 2026, and it continues the project’s emphasis on stronger security defaults and on first-class support for AI workloads. According to the official release page (Source: kubernetes.io/releases, as of 2026-05-20), the project actively maintains release branches for the three most recent minor versions, currently 1.34, 1.35, and 1.36. Version 1.33 entered maintenance on 2026-04-28 and reaches end of life on 2026-06-28. The release cadence is rapid enough that operators running anything older than 1.33 are already outside the supported window.

    Source: kubernetes.io/releases, as of 2026-05-20
    Version Released Status Key features
    1.36 April 2026 Latest, fully supported User Namespaces GA, Mutating Admission Policies GA, Fine-Grained Kubelet API Authorization GA; 70 enhancements total (18 GA / 25 Beta / 25 Alpha)
    1.35 December 2025 Supported DRA improvements for GPU scheduling, Topology-aware routing refinements
    1.34 August 2025 Supported VolumeAttributesClass GA, Direct Service Return + overlay networking in Windows kube-proxy
    1.33 April 2025 Maintenance only (EOL 2026-06-28) Sidecar containers GA, in-place pod resize beta

     

    The promotion of User Namespaces to general availability is the most prominent security change in 1.36. When user namespaces are enabled, the root user inside a container is mapped to an unprivileged user on the host. This arrangement substantially reduces the impact of a container escape: even when an attacker compromises a container running as UID 0, they emerge on the host as a high-numbered unprivileged user, such as UID 100000, with no special privileges. For database pods specifically, a compromised Postgres container no longer translates directly into root access on the node. In combination with seccomp and AppArmor profiles, this change closes one of the long-standing gaps between Kubernetes security and traditional virtual machine isolation.

    Mutating Admission Policies, also promoted to general availability, bring declarative mutations expressed in the Common Expression Language (CEL) to the admission chain, replacing many uses of webhook-based mutating admission controllers. Operators can now write policies that, for example, automatically inject sidecar containers, attach labels, set default resource requests, or enforce image-registry rules, without operating a separate webhook server. The result is less infrastructure to maintain and fewer failure modes when a webhook becomes unavailable.

    Fine-Grained Kubelet API Authorization, now generally available, allows the kubelet to enforce per-verb RBAC on its own API rather than treating all operations uniformly. This change matters for hardening: tools that require nodes/proxy can be restricted to read-only operations, and the kubelet can refuse risky combinations that previously required cluster-admin privileges in order to be fully restricted.

    Beyond security, version 1.36 continues to invest in AI workload support. It introduces refinements to Dynamic Resource Allocation (DRA) for GPU scheduling, adds support for accelerator partitioning, and improves the ability of the scheduler to handle long-running training jobs alongside short-lived inference pods. The trajectory is clear: the pattern of Kubernetes as an AI platform, which grew rapidly in 2024 and 2025 as model-serving workloads migrated off bespoke infrastructure, has been a first-class concern for two consecutive release cycles. For language and runtime choices when developing operators or controllers around these new APIs, the Python and Rust comparison provides a useful framing. The controller-runtime ecosystem in Go remains dominant, but Rust-based operators are gaining ground for performance-sensitive components.

    Frequently Asked Questions

    Can a pod have more than one container?

    Yes, and it is a common design pattern. The most frequent reason is the sidecar — a helper container that does logging, TLS termination, service-mesh proxying (Envoy in Istio or Linkerd), or connection pooling. All containers in a pod share a network namespace and can share volumes, but they remain separate processes with separate filesystems. Use multiple containers when their lifecycles are genuinely coupled. If the answer to “can these scale independently?” is yes, they belong in separate pods.

    Why not just expose every database pod with a NodePort and connect directly?

    NodePort opens the same port on every node in the cluster, in the 30000–32767 range, and routes it to whichever pod backs the Service. Three problems: the port numbers are non-standard so client tooling fights you, every node becomes an attack surface for the database, and you still need a cloud security group or firewall rule to control who can hit those ports. NodePort is fine for on-prem clusters without a cloud LB or for very specific debug scenarios. It is not a substitute for proper Service architecture.

    Is kubectl port-forward safe to use in production?

    It is safe to use, but it should not be how production traffic flows. The tunnel runs through the API server and consumes API-server resources. It bypasses NetworkPolicy — if you can port-forward, you can connect, regardless of how strict your in-cluster policies are. RBAC controls who can use it, and you should treat pods/portforward on a database namespace as a sensitive verb subject to audit. For production traffic, use a real Service.

    What is the difference between a StatefulSet and a Deployment?

    A Deployment treats pods as interchangeable. It will scale up by spinning up new pods with random suffix names, scale down by killing any of them, and roll updates in parallel. A StatefulSet maintains ordered, named pods (name-0, name-1, name-2) that always come up in order, always shut down in reverse order, and each get their own stable PersistentVolumeClaim. Use Deployment for stateless apps. Use StatefulSet for anything that has identity — databases, message brokers, ZooKeeper, distributed coordination services. Kafka brokers running in Kubernetes are a textbook StatefulSet workload.

    Should I actually run my database in Kubernetes, or use a managed service?

    For most teams below the scale of needing a database engineer on the org chart, managed (RDS, Cloud SQL, Aurora, AlloyDB, Spanner) is the right answer. Operating a stateful workload well — backups, point-in-time recovery, minor-version upgrades, failover, performance tuning, observability — is a continuous engineering investment that managed services amortize across thousands of customers. Run databases in your cluster when you have a real reason: cost at scale, regulatory data residency, latency requirements that make a separate database tier unworkable, or a database that managed offerings do not provide. The operator ecosystem (CloudNativePG and friends) makes this much more tractable than it was five years ago, but it is still real work.

    The following companion guides examine the surrounding stack in greater depth:

    References

    Conclusion

    Connecting to a database that runs in a Kubernetes pod feels harder than it should because Kubernetes is solving a different problem than many engineers initially assume. It is not an elaborate replacement for docker run. It is a cluster operating system whose entire networking model is designed around the principle that pods communicate with other pods through stable abstractions, and external clients reach applications through carefully chosen entry points. The pod IP revealed by kubectl get pods -o wide is a debugging convenience rather than an address suitable for client traffic. The ClusterIP shown by kubectl get svc is a virtual construct held together by iptables rules. The correct address for production traffic originating inside the cluster is a DNS name served by CoreDNS and backed by a Service whose membership the controllers maintain. The correct address from outside the cluster is whatever the LoadBalancer, Ingress, or bastion-host configuration specifies, and it is never a pod IP.

    Three points are worth retaining from this discussion. First, kubectl port-forward is well suited to development workflows and unsuited to production traffic. Second, stateful workloads require a StatefulSet, a PersistentVolumeClaim, and a headless Service in combination, or data loss is likely. Third, in Kubernetes 1.36 and beyond, security defaults are tightening, with User Namespaces reaching general availability as the most consequential change, which benefits anyone running databases in pods. Even with these improvements, however, the number of ways in which a connection between an external client and an in-cluster database can fail remains large enough that exposing Postgres directly to the public internet is almost always inferior to placing an API tier in front of the database. The recommended approach is to build the conservative, layered version first, and to reserve more aggressive shortcuts for cases that genuinely warrant them.

  • xPatch Explained: Dual-Stream Time Series Forecasting with EMA Decomposition

    PatchTST established the prevailing benchmark for transformer-based time series forecasting. A subsequent paper from KAIST then demonstrated a less comfortable result: a non-transformer model composed of two simple streams, an MLP and a CNN, outperforms PatchTST. xPatch achieves this with approximately one-quarter of the compute and an established idea, namely exponential moving averages.

    The paper is xPatch: Dual-Stream Time Series Forecasting with Exponential Seasonal-Trend Decomposition by Artyom Stitsyuk and Jaesik Choi, published at AAAI 2025 (arXiv:2412.17323). It is the type of paper that quietly recalibrates the field. There is no new attention variant, no foundation model with 100 billion parameters, only a careful re-examination of which inductive biases actually contribute to forecasting performance for electricity load, traffic, weather, or stock returns.

    This article examines in detail every load-bearing component of the paper: the EMA decomposition, the dual-stream architecture, the arctangent loss, the sigmoid learning-rate schedule, the experimental results, and the implications for practitioners deploying forecasts in production.

    Summary

    What this post covers: A detailed examination of the AAAI 2025 xPatch paper by Stitsyuk and Choi, including its EMA decomposition, dual-stream MLP and CNN architecture, training methods (arctangent loss, sigmoid learning-rate schedule, RevIN), benchmark results, and the implications for transformer-dominated time-series forecasting.

    Key insights:

    • A non-transformer dual-stream model (a linear stream for the trend and a depthwise-separable CNN for the seasonal component) outperforms CARD, the previous current best, by an average of 2.46 percent in MSE and 2.34 percent in MAE across eight standard benchmarks, while running approximately four times faster.
    • The appropriate inductive bias (EMA trend-seasonal decomposition combined with patching and dual specialisation) consistently outperforms generic attention for typical multivariate forecasting, echoing the earlier critique advanced by DLinear in “Are Transformers Effective?”
    • Training-side techniques contribute meaningfully to performance. The arctangent loss (a horizon-weighted MAE that prevents any single horizon from dominating the gradient) and the sigmoid learning-rate schedule also transfer to PatchTST and CARD, suggesting that many architecture comparisons in the literature have employed suboptimal training recipes.
    • The recommended default for the EMA alpha is 0.3 on large benchmarks (Weather, Traffic, Electricity). On smaller or noisier datasets, a sweep over {0.1, 0.3, 0.5, 0.7, 0.9} is appropriate. A smaller alpha produces smoother trends, while a larger alpha produces more reactive trends.
    • xPatch is preferable to PatchTST as a production default unless the application involves heavy channel correlations that benefit from cross-channel attention, or requires a look-back longer than 96 steps. xPatch is faster to train, faster to infer, slightly more accurate, and easier to debug because the two streams are individually interpretable.

    Main topics: Why this paper matters, The EMA Decomposition at the Centre of xPatch, The Dual-Stream Architecture, Training Components: Arctangent Loss, Sigmoid Schedule, and RevIN, Benchmark Results, Ablations: What Drives Performance, How to use xPatch (PyTorch sketch), When to use xPatch versus alternatives, Limitations and open questions, Implications for the Field, Frequently asked questions.

    Why this paper matters

    For approximately three years, time series forecasting has been dominated by transformer-based models. Informer (2021) made attention practical for long sequences. Autoformer (2021) incorporated series decomposition. FEDformer (2022) shifted attention into the frequency domain. PatchTST (2023) adapted the patching technique from Vision Transformers and became the strongest model on a substantial set of benchmarks. iTransformer (2024) inverted the embedding dimension. CARD (2024) refined the channel-aligned attention design.

    DLinear, introduced in 2022, raised an awkward question: is attention actually required for forecasting? A two-line linear model, consisting of a single fully connected layer with a moving-average decomposition, could match or surpass several transformer variants on standard benchmarks. The community responded with a wave of “are transformers effective” papers, and the consensus that emerged was nuanced: transformers help on some datasets, harm on others, and the gains are often smaller than the speed advantages forgone.

    xPatch takes the next logical step. Rather than abandoning the transformer entirely (as DLinear does) or retaining a transformer while refining attention (as CARD and iTransformer do), it constructs a dual-stream non-transformer model with stronger inductive biases. One stream is a simple MLP. The other is a compact depthwise-separable CNN. Combined with EMA-based decomposition and an improved loss function, the result outperforms CARD, the previous current best, while training approximately four times faster.

    For an overview of the broader landscape in which these models operate, see the companion overview of time series forecasting models in 2026. xPatch is one of the clearest examples of a non-foundation-model approach that continues to deliver competitive performance on real benchmarks.

    Key Takeaway: xPatch provides evidence that for typical multivariate forecasting, appropriate inductive biases (decomposition, patching, and dual specialisation) contribute more than attention itself. Architecture is not the only frontier; loss functions and learning-rate schedules also account for a substantial share of observed performance differences.

    xPatch: Dual-Stream Architecture Input X L × N RevIN normalize EMA decomposition X_T (trend) X_S = X − X_T Linear Stream (X_T) FC → AvgPool(k=2) → LN FC → AvgPool(k=2) → LN no activation, project → T CNN Stream (X_S) Patch P=16, S=8 Depthwise (k=P) → Pointwise GELU → BatchNorm → residual Concat + Linear de-RevIN → Ŷ Linear stream handles smooth trend; CNN stream handles bursty seasonal patterns.

    The EMA Decomposition at the Centre of xPatch

    The single most important point to retain about xPatch is the following: the model’s first operation is to separate every channel of the input series into a slow component and a fast component, and then to model each component with a distinct network. The separation is performed using an exponential moving average.

    Why decomposition matters

    Trend and seasonality have fundamentally different dynamics. A trend is slow, often nearly linear over short windows, and dominated by accumulating shifts in level. A seasonal component is fast, often locally periodic, and frequently bursty (for example, traffic spikes or weather fronts). If one network is asked to model both at once, it must compromise: smooth filters blur the seasonal spikes, while sharp filters chase the trend’s drift. Decomposition removes that conflict by assigning each component to a specialist.

    This is not a new idea. Classical statistics has applied decomposition for decades:

    • STL (Seasonal-Trend decomposition using Loess): local polynomial regression for seasonality extraction.
    • Holt-Winters: three exponential smoothers (level, trend, and seasonal) chained together.
    • X-11 / X-13ARIMA-SEATS: a workhorse of official statistics based on iterative moving averages.

    Recent machine-learning approaches retained the spirit of decomposition while employing different tools. DLinear used a simple moving-average filter, and FEDformer projected the series into the frequency domain via Fourier transforms. xPatch adopts a different choice: an exponential moving average.

    The recursive formula

    The EMA decomposition is defined by Equation 2 of the paper:

    s₀ = x₀
    sₐ = α · xₐ + (1 - α) · sₐ₋₁    for t > 0
    
    X_T = EMA(X)         (trend)
    X_S = X − X_T        (seasonal residual)

    The parameter α is the smoothing factor, taking values in (0, 1). A small α (such as 0.1) produces a very smooth trend dominated by older observations, while a large α (such as 0.9) causes the trend to track the most recent value almost immediately. The seasonal stream consists of whatever the trend cannot explain.

    The recursion appears computationally expensive, since it is sequential by definition. However, Appendix D of the paper presents a vectorised form with O(1) per-step cost in terms of GPU operations. The technique is to expand the recursion into a closed-form weighted sum and compute it as a single matrix multiplication with a Toeplitz-style weight matrix. In practice, the EMA pre-processing is essentially free relative to the rest of the forward pass.

    Why α = 0.3 performs best on large datasets

    The paper sweeps α over {0.1, 0.3, 0.5, 0.7, 0.9}. On Weather, Traffic, and Electricity, the larger and more channel-rich benchmarks, α = 0.3 is consistently optimal. The intuition is as follows. With many noisy channels, the trend must be genuinely slow in order to filter short-lived noise while still tracking the multi-step drift. A smaller α oversmooths and deprives the seasonal stream of bandwidth, whereas a larger α allows excessive high-frequency content to leak into the trend. The value 0.3 sits in the appropriate range.

    On smaller and noisier datasets the result is less clear-cut. In some cases α = 0.5 or 0.7 is preferable because the trend must react more quickly to abrupt regime changes. The paper treats α as a hyperparameter rather than a learnable parameter; making α learnable is one obvious direction for follow-up research.

    Simple moving average versus exponential moving average

    Property Simple Moving Average (DLinear-style) Exponential Moving Average (xPatch)
    Weight scheme Uniform inside a window Geometric decay, recent > old
    Hyperparameter Window length k Smoothing factor α
    Edge effects Hard window boundary Smooth, no boundary discontinuity
    Reactivity to recent shocks Slow (averaged equally with old data) Fast (recent point gets weight α)
    Implementation cost O(k) per step O(1) per step (vectorized)

     

    EMA Decomposition (α = 0.3) Original X Trend X_T = EMA(X) sₐ = α·xₐ + (1−α)·sₐ₋₁ Seasonal X_S = X − X_T Trend: smooth low-pass via EMA. Seasonal: bursty residual carries the high-frequency structure.

    The Dual-Stream Architecture

    Once X_T (the trend) and X_S (the seasonal component) are obtained, xPatch processes them in two specialised streams. The design principle is to use the appropriate tool for each component and combine the results at the end.

    The linear stream (processing X_T)

    The trend is, by construction, smooth. After EMA filtering, little non-linear structure remains. xPatch therefore processes the trend through two MLP-style blocks, each composed of:

    • A fully connected (FC) projection.
    • A 1D average pooling layer with kernel size k = 2.
    • A LayerNorm operation.

    Importantly, there is no non-linear activation function anywhere in the linear stream. Up to the LayerNorm, the entire stream consists of a sequence of linear operators. The final output is projected to dimension T (the forecast horizon). Readers familiar with DLinear will recognise the structure: xPatch retains the DLinear approach for trend modelling.

    The LayerNorm is the only operator in the stream with a non-linear character, since it divides by an instance-computed standard deviation that is data-dependent. It stabilises training when the trend’s scale varies across samples. The average pooling acts as an additional smoothing step, reducing the probability that the linear stream over-fits to high-frequency noise that leaks through the decomposition.

    The CNN stream (processing X_S)

    The seasonal stream is where most of the modelling work occurs. Seasonal residuals are bursty, locally periodic, and channel-correlated. xPatch handles them with a depthwise-separable CNN:

    • Patching: the input is segmented into patches of length P = 16 with stride S = 8. The number of patches is N = ⌊(L − P) / S⌋ + 2, matching the PatchTST configuration. With L = 96, the result is approximately 12 patches per channel.
    • Depthwise convolution: kernel size P = 16, stride P = 16, with groups equal to the number of channels N. Each channel receives its own filter aligned to patch boundaries, with no cross-channel mixing at this step.
    • Pointwise convolution: a 1×1 convolution that mixes information across channels.
    • GELU activation: the only major non-linearity in the entire model. The smooth saturating shape of GELU is well suited to spiky residuals.
    • BatchNorm: applied for training stability across batches.
    • Residual connection: the input is added back to the output, which simplifies optimisation and allows the stream to behave approximately as an identity if the seasonal component is near zero.

    The depthwise plus pointwise pattern is the classic MobileNet-style separable convolution. It reduces parameters substantially relative to a full convolution while retaining a similar receptive field. For time series with many channels (Traffic has 862 and Electricity has 321), the reduction is essential, since a full Conv1D would be prohibitively large.

    Why this division of labour is effective

    An MLP can learn arbitrary linear projections but must allocate capacity to discover local structure. A patch-aligned CNN encodes locality and translation-equivariance directly into the architecture. By passing only the seasonal residual into the CNN, xPatch allows the CNN to concentrate on local patterns, the task it is best suited to, without expending capacity on re-learning the trend. Conversely, the linear stream is not required to model seasonal spikes that would force a compromise.

    This is the same lesson that graph attention networks illustrate in a different domain: the architecture’s inductive biases should align with the structure of the signal being modelled. Attention is a powerful general-purpose mixer, but its generality is not free.

    Combining the two streams

    The outputs of the linear and CNN streams are concatenated and passed through a final linear layer (Equation 12 in the paper) to produce the forecast over horizon T. The combination is intentionally simple. The model is not required to learn a complex gating mechanism; it learns a linear combination of the two specialists’ outputs.

    Tip: For implementations starting from scratch, an effective sanity check is to begin with the linear stream alone and verify that it matches DLinear performance on ETTh1. The CNN stream can then be added, and the gains will become visible on noisier datasets such as Weather and Traffic.

    Training Components: Arctangent Loss, Sigmoid Schedule, and RevIN

    The architecture is only half of the story. The other half is the training recipe, and the paper makes a strong case that some of the gains derive from techniques that any forecasting model can adopt.

    RevIN (Reversible Instance Normalisation)

    Distribution shift is endemic in time series. The mean and variance of a channel during training rarely match those at inference time, particularly in non-stationary domains such as finance, traffic, or weather. RevIN addresses this issue with a simple procedure:

    1. Before the model: subtract the per-instance mean and divide by the per-instance standard deviation, where the instance is a single look-back window.
    2. After the model: multiply by the same standard deviation and add back the same mean, along with learnable affine parameters.

    The model therefore only sees standardised inputs and does not need to memorise the level or scale of any particular channel. The de-normalisation at the output returns the forecast to the original scale. RevIN is now standard equipment in modern forecasting models, and xPatch employs it in the same manner as PatchTST and CARD.

    The arctangent loss

    This is one of the more novel components of the paper. CARD popularised a horizon-weighted loss that assigns greater importance to longer-horizon predictions, with weights that grow exponentially. The motivation is reasonable, since long-horizon errors compound, but exponential weighting grows quickly and can dominate the optimisation.

    xPatch replaces this with a slower-growing function based on the arctangent (Equations 16 and 17):

    ρ_arctan(i) = −arctan(i) + π/4 + 1
    
    L_arctan = (1/T) · Σᵢ ρ_arctan(i) · ||Ŷᵢ − yᵢ||₁

    The motivation for the arctangent function is that it is bounded (growth slows asymptotically), monotonic, and smooth. Unlike exponential weighting, it does not allow any single horizon to dominate the gradient. The result is more uniform attention across the entire forecast window, which empirically translates into improved performance on long horizons without degrading performance on shorter ones.

    The paper’s most notable ablation finding is that the arctangent loss helps even when applied to other models. Substituting it into PatchTST or CARD improves accuracy. The loss is therefore a transferable technique that can serve as a free upgrade for an existing forecasting pipeline.

    Sigmoid learning-rate schedule

    Standard schedules in this literature are step decay (the learning rate is halved every K epochs) or cosine annealing. xPatch introduces a sigmoid-shaped schedule (Equation 23) with a warm-up parameter w. The shape consists of a smooth ramp-up from a low initial value, a flat plateau in the middle, and a gentle ramp-down. Compared with step decay, it avoids the discontinuities that can destabilise training. Compared with cosine annealing, the explicit warm-up provides the optimiser with time to locate a suitable basin before the learning rate becomes high.

    As with the arctangent loss, the paper shows that the sigmoid schedule transfers cleanly to other models. The implication is that learning-rate schedules are often under-tuned in benchmark comparisons. When all models use the same default, any architecture that claims a win must outperform the also-suboptimal training of every competitor.

    Compute footprint

    xPatch is trained for 100 epochs on a single NVIDIA Quadro RTX 6000. The configuration corresponds to a single mid-range GPU and a short schedule by current standards. There is no foundation-model pre-training, no distributed setup, and no specialised quantisation. This minimal footprint is part of the paper’s argument: current best forecasting does not necessarily require current best compute.

    Caution: The arctangent loss assumes that all horizons matter equally. If the downstream application weights the next-step forecast more heavily (for example, real-time anomaly detection on the next minute), the weighting should be shifted toward shorter horizons, or a custom ρ function should be used. The paper’s choice is well motivated for the standard MSE-on-all-horizons benchmark, but it is not necessarily optimal for every production setting.

    Benchmark Results

    The experimental setup is the standard long-horizon forecasting suite that has dominated the literature since Informer.

    Datasets

    Dataset Dim Frequency Forecast horizons
    ETTh1, ETTh2 7 Hourly 96, 192, 336, 720
    ETTm1, ETTm2 7 15 min 96, 192, 336, 720
    Weather 21 10 min 96, 192, 336, 720
    Traffic 862 Hourly 96, 192, 336, 720
    Electricity 321 Hourly 96, 192, 336, 720
    Exchange-rate 8 Daily 96, 192, 336, 720
    Solar 137 10 min 96, 192, 336, 720
    ILI 7 Weekly 24, 36, 48, 60

     

    The look-back window is L = 96 for all datasets except ILI, which uses L = 36. The baselines are the principal models of the past few years: Autoformer, FEDformer, ETSformer, TimesNet, DLinear, RLinear, MICN, PatchTST, iTransformer, TimeMixer, and CARD.

    Headline numbers

    Dataset Horizon xPatch MSE xPatch MAE
    ETTh1 96 0.428 0.419
    Weather 720 0.310 0.322

     

    Across all eight datasets and all four horizons, xPatch outperforms CARD, the previous current best, by an average of 2.46 percent in MSE and 2.34 percent in MAE. The margin is small but clear, given how saturated these benchmarks have become. Gains of 1 to 3 percent are now considered meaningful in the literature, and such gains are typically obtained at the cost of new attention variants, larger models, or longer training.

    Speed

    While accuracy is the headline result, the speed advantage is equally important. Table 3 of the paper reports per-step training and inference times.

    Model Training (msec/step) Inference (msec/step) Relative speed vs xPatch
    xPatch 3.099 1.303 1.0×
    CARD 14.877 4.8× slower

     

    Training is approximately 4.8 times faster than CARD per step. The paper does not provide equivalently precise per-step numbers for PatchTST and DLinear, but the general ordering reported is DLinear < xPatch < PatchTST < CARD in training time. In production settings, where forecasting models may be retrained daily on streaming data, this speed advantage matters more than the marginal MSE gain.

    Speed vs Accuracy: xPatch is Pareto-optimal Training time per step (msec) — lower is better MSE — lower is better 1 3 7 12 15 20 0.42 0.44 0.46 0.48 0.50 DLinear (1 msec, 0.50) iTransformer (~10 msec, ~0.46) PatchTST (~7 msec, ~0.45) CARD (15 msec, 0.44) xPatch (3 msec, 0.43) — Pareto-optimal MSE values are illustrative averages across benchmarks; xPatch achieves both lower MSE and faster training than CARD/PatchTST.

    Ablations: What Drives Performance

    Ablation studies indicate whether a paper’s gains are robust or fragile. The ablations reported for xPatch are transparent and informative.

    EMA α sweep

    α Weather Traffic Electricity Notes
    0.1 slightly worse slightly worse slightly worse Trend too smooth, leaks structure
    0.3 best best best Optimal balance for big datasets
    0.5 close close close Reasonable fallback
    0.7 worse worse worse Trend tracks too fast
    0.9 worst worst worst Trend ~= input, decomposition fails

     

    The pattern is clear: 0.3 dominates on the larger datasets. The paper notes that smaller and noisier datasets sometimes favour higher α values, so fixing α = 0.3 for every problem is unwise. The parameter should instead be swept on a held-out validation split.

    Necessity of both streams

    The paper ablates the removal of each stream. Removing the linear stream (so that the CNN handles both trend and seasonal components) degrades performance. Removing the CNN stream (so that the linear stream attempts to capture seasonality) degrades performance more substantially. The two streams are genuinely complementary, and neither is dispensable.

    Transferability of the arctangent loss

    This is arguably the most important ablation in the paper. When the standard MSE loss in PatchTST or CARD is replaced with the arctangent loss, those models also improve. The loss is therefore a free upgrade for the field. Practitioners operating an existing forecasting pipeline can adopt the new loss as a one-line change and likely gain a few percentage points in accuracy.

    Transferability of the sigmoid schedule

    The same conclusion applies to the sigmoid schedule: it also helps other models. The implication is uncomfortable for the literature. A non-trivial fraction of past “architecture wins” may have been confounded by suboptimal training schedules. xPatch at least isolates how much of its margin derives from the loss and the schedule, as distinct from the dual-stream design itself.

    Key Takeaway: A meaningful share of the gains attributed to xPatch derives from training methods rather than architecture. The honest reading is that xPatch outperforms on multiple dimensions, including better decomposition, better dual-stream design, a better loss, and a better schedule. Practitioners should consider carefully which of these components to adopt independently.

    How to use xPatch (PyTorch sketch)

    The official implementation is available at github.com/stitsyuk/xPatch and follows the structure of standard long-horizon forecasting library scaffolds. The full code includes data loaders, evaluation harnesses, and configurations for each benchmark, but the model itself is compact enough to summarise in a single screen.

    The following is a minimal but faithful PyTorch outline. It is not a drop-in replacement for the official repository, which should be used for benchmarking, but it represents the architecture clearly.

    import torch
    import torch.nn as nn
    import torch.nn.functional as F
    
    
    class EMADecomp(nn.Module):
        """Exponential moving-average decomposition (Eq. 2)."""
        def __init__(self, alpha: float = 0.3):
            super().__init__()
            self.alpha = alpha
    
        def forward(self, x):
            # x shape: (B, L, N)  batch, look-back, channels
            B, L, N = x.shape
            trend = torch.zeros_like(x)
            trend[:, 0, :] = x[:, 0, :]
            for t in range(1, L):
                trend[:, t, :] = (
                    self.alpha * x[:, t, :]
                    + (1.0 - self.alpha) * trend[:, t - 1, :]
                )
            seasonal = x - trend
            return trend, seasonal
    
    
    class LinearStream(nn.Module):
        """2 FC + AvgPool + LayerNorm blocks, no activation."""
        def __init__(self, L: int, T: int, hidden: int = 128):
            super().__init__()
            self.fc1 = nn.Linear(L, hidden)
            self.pool1 = nn.AvgPool1d(kernel_size=2, stride=1, padding=1)
            self.ln1 = nn.LayerNorm(hidden + 1)
            self.fc2 = nn.Linear(hidden + 1, hidden)
            self.pool2 = nn.AvgPool1d(kernel_size=2, stride=1, padding=1)
            self.ln2 = nn.LayerNorm(hidden + 1)
            self.proj = nn.Linear(hidden + 1, T)
    
        def forward(self, x):
            # x: (B, L, N) -> (B, N, L)
            x = x.transpose(1, 2)
            h = self.pool1(self.fc1(x).transpose(1, 2)).transpose(1, 2)
            h = self.ln1(h)
            h = self.pool2(self.fc2(h).transpose(1, 2)).transpose(1, 2)
            h = self.ln2(h)
            return self.proj(h)  # (B, N, T)
    
    
    class CNNStream(nn.Module):
        """Patch -> depthwise -> pointwise -> GELU -> BN -> residual."""
        def __init__(self, N: int, L: int, T: int,
                     P: int = 16, S: int = 8):
            super().__init__()
            self.P, self.S = P, S
            n_patches = (L - P) // S + 2
            self.depthwise = nn.Conv1d(
                in_channels=N, out_channels=N,
                kernel_size=P, stride=P, groups=N,
            )
            self.pointwise = nn.Conv1d(N, N, kernel_size=1)
            self.bn = nn.BatchNorm1d(N)
            self.proj = nn.Linear(n_patches * P, T)
    
        def forward(self, x):
            # x: (B, L, N) -> (B, N, L)
            x = x.transpose(1, 2)
            h = self.depthwise(x)
            h = self.pointwise(h)
            h = F.gelu(h)
            h = self.bn(h)
            # residual: pad and add (omitted for brevity)
            h = h.flatten(start_dim=2)
            h = F.pad(h, (0, max(0, self.proj.in_features - h.size(-1))))
            return self.proj(h[..., :self.proj.in_features])
    
    
    class XPatch(nn.Module):
        def __init__(self, L: int, T: int, N: int, alpha: float = 0.3):
            super().__init__()
            self.decomp = EMADecomp(alpha)
            self.linear_stream = LinearStream(L, T)
            self.cnn_stream = CNNStream(N, L, T)
            self.fuse = nn.Linear(2 * T, T)
    
        def forward(self, x):
            # RevIN
            mean = x.mean(dim=1, keepdim=True)
            std = x.std(dim=1, keepdim=True) + 1e-5
            x_norm = (x - mean) / std
    
            trend, seasonal = self.decomp(x_norm)
            y_lin = self.linear_stream(trend)        # (B, N, T)
            y_cnn = self.cnn_stream(seasonal)        # (B, N, T)
            y = torch.cat([y_lin, y_cnn], dim=-1)
            y = self.fuse(y).transpose(1, 2)         # (B, T, N)
    
            # de-RevIN
            return y * std + mean
    
    
    def arctangent_loss(pred, target):
        """L_arctan from Eq. 16-17."""
        T = pred.size(1)
        i = torch.arange(T, device=pred.device, dtype=torch.float32)
        rho = -torch.atan(i) + torch.pi / 4 + 1.0
        abs_err = (pred - target).abs().mean(dim=-1)  # (B, T)
        return (rho * abs_err).mean()
    

    Several practical notes apply:

    • The Python loop in EMADecomp should be replaced with the vectorised closed-form for a genuine speed-up. The mathematics is presented in Appendix D of the paper, and the official repository implements the vectorised version.
    • The CNN stream’s output projection is sketched in a simplified manner here; the official implementation handles the patching dimensions more carefully.
    • For a clean initial configuration, use L = 96, P = 16, S = 8, α = 0.3, 100 epochs, the sigmoid learning-rate schedule with a warm-up of approximately 10 epochs, and the arctangent loss.

    For applications involving anomaly detection on the same series, the overview of time series anomaly detection models is relevant. Many of the same training techniques (RevIN, patching, decomposition) carry over.

    Hyperparameter reference

    Hyperparameter Default When to change
    Look-back L 96 (36 for ILI) Increase if your seasonality is longer than 96 steps
    Patch size P 16 Should align with your series’ natural local period
    Stride S 8 Smaller for more overlap, larger for fewer patches
    EMA α 0.3 Sweep {0.1, 0.3, 0.5, 0.7, 0.9} on small/noisy data
    Epochs 100 Use early stopping to cut wasted compute
    Loss Arctangent Switch to standard MAE if all horizons matter equally

     

    When to use xPatch versus alternatives

    No single model is appropriate for every problem. xPatch occupies a specific region of the design space: low-latency, accuracy-competitive, supervised, point-forecast, and multivariate. The following framework is useful for selecting an appropriate model.

    Need Recommended approach Why
    Fastest training/inference, good accuracy xPatch Beats CARD, ~5× faster than CARD per training step
    Foundation model / zero-shot TimesFM, Chronos, Moirai Pretrained at scale, generalize across domains without fine-tuning
    Calibrated uncertainty estimates Gaussian processes Native posterior variances, principled credible intervals
    Long-context attention reasoning PatchTST, iTransformer When channel relationships are essential and context exceeds ~512 steps
    Tabular-style features without temporal structure XGBoost / LightGBM When good lag/window features can be engineered, GBMs are difficult to beat on tabular forecasting
    Linear/stationary signal, minimal compute DLinear, classical ARIMA If the data is genuinely simple, simpler is better
    High-throughput streaming infra xPatch + Kafka time-series engine Low-latency model fits well with streaming pipelines

     

    For principled tuning of hyperparameters in any of these alternatives, the companion note on Bayesian hyperparameter optimisation is a useful reference.

    Limitations and open questions

    xPatch is a strong paper, but no paper is without weaknesses. The honest limitations are as follows:

    • α is a hyperparameter rather than a learned parameter. A natural extension is to make α differentiable, or even to make it both per-channel and per-timescale. The paper acknowledges this and identifies it as future work.
    • The datasets are relatively small. The largest is Traffic, with 862 channels and approximately 17,000 timesteps. This is small compared with the data on which foundation models such as Chronos and TimesFM are pre-trained. The behaviour of xPatch on substantially larger streams remains untested in the paper.
    • Two streams imply two forward passes. Inference remains fast, but a fused single-pass implementation would be faster still and might be feasible with a careful architectural redesign.
    • The model produces point forecasts only. xPatch produces a single-trajectory forecast without a probabilistic interpretation. For risk-sensitive applications such as finance, energy, and healthcare, quantiles or full distributions are typically required, and xPatch does not provide them natively. A quantile head or a Bayesian wrapper is necessary.
    • Benchmark saturation. The community has acknowledged that ETTh, Weather, and related benchmarks are showing signs of saturation. Gains of 2 to 3 percent may not transfer to messier real-world data with greater drift, missing values, and concept shift. xPatch’s results are current best on these benchmarks; whether they generalise to, for example, the tick data of a finance trading desk is an empirical question.
    • The paper presents no theoretical analysis. The contribution is empirical. There is no generalisation bound, no convergence proof for the recursion, and no analysis of the loss landscape. This is acceptable for an applied paper but leaves room for follow-up theory.
    Caution: If an application is characterised by heavy concept drift (for example, post-COVID demand forecasting or regime-changing financial markets), benchmark gains do not automatically transfer. Practitioners should evaluate on their own data with a realistic backtest before relying on leaderboard results.

    Implications for the Field

    Considered at a higher level, the broader narrative is more interesting than the architectural details alone:

    • Inductive biases continue to matter. Decomposition (the separation of trend from seasonality) has been valuable since the 1950s, and it remains valuable in 2025. Patching, locality, and dual-specialisation all encode useful priors. Generic attention without such priors is rarely the appropriate choice for time series.
    • Loss functions and learning-rate schedules are underrated. The fact that the arctangent loss and the sigmoid schedule transfer to other models suggests that the field has been comparing architectures under suboptimal training. Future benchmark papers should standardise the training recipe before claiming architectural wins.
    • The Pareto frontier is the appropriate evaluation axis. A model that is 1 percent more accurate but 10 times slower may not be worth deploying. xPatch occupies the region in which accuracy is competitive and speed is meaningfully better, which is the appropriate position for production systems.
    • Foundation models are not the only path forward. The same year that produced TimesFM and Chronos also produced xPatch, which is task-specific, compact, fast, and competitive. Both styles will coexist; the appropriate choice depends on deployment constraints.
    • Self-supervised pre-training remains an open opportunity. xPatch is fully supervised. Whether self-supervised pre-training of the CNN stream, analogous to TS2Vec and related methods, would unlock further gains is an open question. The overview of self-supervised pretraining covers the relevant techniques.

    For a concise reminder of the statistical foundations on which these models rest (independence, the role of variance, the importance of sample size for stable estimators), the explainer on the Central Limit Theorem is relevant. For deployment considerations, the comparison of databases for preprocessed time series reviews the relevant trade-offs.

    Frequently asked questions

    Why does a non-transformer model outperform PatchTST?

    Three factors combine. First, the EMA decomposition provides the model with two cleaner sub-signals rather than a single mixed signal. Second, the dual-stream architecture matches the appropriate tool to each component: a linear stream for the smooth trend and a CNN for the bursty seasonal residual. Third, the arctangent loss and the sigmoid learning-rate schedule provide a training-side improvement. PatchTST employs channel-independent attention and learnable patching, but it asks a single stack of attention layers to handle both trend and seasonal components simultaneously. xPatch’s specialisation wins by an average of 2.46 percent in MSE while running approximately 4.8 times faster than CARD.

    Should xPatch or PatchTST be used in production?

    The default choice should be xPatch unless there is a specific reason to prefer PatchTST. xPatch is faster to train, faster to infer, slightly more accurate on the standard benchmarks, and easier to debug because the streams are individually interpretable. PatchTST is preferable if the dataset is heavily channel-correlated and the cross-channel mixing of attention is essential, or if a look-back longer than 96 steps is required and the global receptive field of attention is needed.

    How is the EMA alpha parameter tuned?

    The recommended starting point is α = 0.3, which is optimal for the largest benchmarks in the paper (Weather, Traffic, Electricity). For smaller or noisier datasets, a sweep over {0.1, 0.3, 0.5, 0.7, 0.9} on a held-out validation split is appropriate. A smaller α produces smoother trends, which is suitable when noise dominates. A larger α produces more reactive trends, which is suitable when regime changes are abrupt. The paper deliberately keeps α non-learnable; making it learnable is a reasonable research extension.

    What is the arctangent loss and why does it help?

    The arctangent loss replaces standard MSE or MAE with a horizon-weighted MAE in which the weights follow ρ(i) = −arctan(i) + π/4 + 1. The arctangent grows much more slowly than the exponential weighting used by CARD, which prevents any single horizon from dominating the gradient. The result is a more uniform learning signal across all forecast horizons. Empirically, the loss benefits not only xPatch but also other models such as PatchTST and CARD, which makes it a transferable upgrade for any forecasting pipeline.

    Does xPatch support multivariate forecasting?

    Yes. The architecture is designed for multivariate inputs. The depthwise convolution in the CNN stream operates per channel (groups = N), and the pointwise convolution mixes information across channels. The linear stream processes each channel through the same weights while preserving the channel dimension. The paper evaluates on datasets with up to 862 channels (Traffic) without modification.

    Related reading

    Related reading:

    External references

    This article is for informational and educational purposes only. It summarizes a publicly available academic paper and is not a substitute for reading the original. Implementation details should be verified against the official repository before production use.

  • Anomaly Detection Metrics Explained: AUROC, AUPRC, F1, Precision, Recall, FAR

    This guide examines the evaluation metrics that are appropriate for anomaly detection systems, in which the positive class is by definition rare. When 99.9 percent of transactions are legitimate, a model that flags every record as “normal” attains 99.9 percent accuracy while delivering no operational value. The choice of evaluation metric is therefore one of the most consequential decisions in an anomaly detection project.

    The discussion proceeds through the metrics that are relevant for this task, from the basic measures (Precision and Recall) to threshold-independent ranking metrics (AUROC and AUPRC) and the specialised time-series metrics (PA-F1 and VUS). For each metric the formula, the trade-offs, and a full Python implementation are presented so that the material can be applied directly.

    Summary

    What this post covers: A complete reference for selecting and computing anomaly detection metrics, including Precision, Recall, F1, FAR, MCC, AUROC, AUPRC, the time-series variants, and Top-K measures. The discussion presents the formulas, the trade-offs, and the Python implementations for ML engineers building rare-event detectors in fraud, intrusion, defects, and biometrics.

    Key insights:

    • Accuracy is degenerate when anomalies are rare. A constant “normal” predictor can score 99.9 percent, so the first decision in any anomaly-detection project is to discard accuracy as the headline metric.
    • For severely imbalanced data (anomalies below 1 percent), AUPRC is the primary ranking metric and AUROC is secondary. AUROC can appear misleadingly high on heavily imbalanced data because the TN count dominates the denominator.
    • Different stakeholders require different metrics for the same model. Engineers focus on AUROC and AUPRC, operations focuses on FAR and alert volume, and finance focuses on dollar-weighted recall. A single number is therefore always a stakeholder choice in disguise.
    • Standard point-wise F1 fails for time-series anomalies because real anomalies are contiguous events, not isolated samples. Range-based F1, VUS, or NAB Score should be used instead.
    • Most production teams should report a small bundle: AUPRC, Precision@K, Recall, and FAR. This combination covers model quality, operational alert volume, miss rate, and false-alarm rate together.

    Main topics: why anomaly metrics matter, the confusion matrix foundation, threshold-dependent metrics, threshold-independent metrics, a decision framework for picking metrics, time-series-specific metrics, Top-K ranking metrics, Python implementations, threshold selection for production, common pitfalls, and domain reporting templates.

    Why Anomaly Detection Metrics Matter and Why Accuracy Does Not

    Consider a scenario in which a team builds a fraud detector and reports that it attains 99.9 percent accuracy. The result appears impressive. When a stakeholder asks how many actual fraud cases the system caught in the previous quarter, however, the answer may be none. The model achieves 99.9 percent accuracy by predicting “not fraud” for every transaction, because the base rate of fraud at a typical payment processor is approximately 0.1 percent. The model is in effect a constant, the accuracy figure is real, and the system is operationally worthless.

    This is the foundational point of anomaly detection: the positive class, namely the anomaly, is rare and sometimes extremely rare. Network intrusions, manufacturing defects, credit-card fraud, and rare diseases all have base rates between approximately 0.01 percent and 5 percent. When the negative class dominates, accuracy becomes a degenerate metric, and a model that predicts “normal” for every input will appear excellent.

    This is the imbalance problem. A second issue is equally important: cost asymmetry. Missing a true anomaly (a false negative) almost always costs more than flagging a legitimate event by mistake (a false positive). A missed credit-card fraud may cost $5,000, while an unnecessary alert costs perhaps 30 seconds of an analyst’s time. These errors are not symmetric, and the chosen metric must reflect the asymmetry.

    Different stakeholders are concerned with different metrics for the same model:

    • The ML engineer requires AUROC and AUPRC for comparing model architectures.
    • The product manager requires Precision@K because the user interface shows the top 50 alerts per day.
    • The operations lead requires False Alarm Rate (FAR) and Mean Time To Detect (MTTD) because analysts must triage every alert.
    • The CFO requires dollar-weighted recall, namely the fraction of fraud value caught, rather than the count of incidents.

    The selection of a single number to optimise implicitly entails a stakeholder choice. The appropriate response is to report a small set of complementary metrics so that each audience receives the information that it requires.

    Key Takeaway: Accuracy is almost never the appropriate metric for anomaly detection. The base rate is too low, and the cost of false negatives is too high. Precision, Recall, F1, AUPRC, and FAR should be used in combinations selected according to the operational objective.

    The Confusion Matrix Foundation

    Every metric in this guide is built from four numbers, namely the cells of the confusion matrix. By convention, in anomaly detection the anomaly is the positive class and the normal point is the negative class.

    Term Definition Fraud Example
    True Positive (TP) Model predicts anomaly, truly is anomaly Caught a fraudulent transaction
    False Positive (FP) Model predicts anomaly, truly is normal Flagged a legitimate purchase
    True Negative (TN) Model predicts normal, truly is normal Correctly cleared a normal payment
    False Negative (FN) Model predicts normal, truly is anomaly Missed a fraudulent transaction

     

    The following is a worked example. Consider 10,000 credit-card transactions in which 100 are fraudulent (a 1 percent anomaly rate) and the model produces the predictions shown below:

    Confusion Matrix—Fraud Detection (1% anomaly rate) Predicted Anomaly (positive) Normal (negative) Actual Anomaly Normal TP = 95 caught fraud (of 100 frauds) FN = 5 missed fraud (slipped past) FP = 30 false alarm (of 9,900 normals) TN = 9,870 correctly cleared normal traffic Derived Metrics Precision = 95/(95+30) = 0.760 Recall = 95/(95+5) = 0.950 F1 = 2·P·R/(P+R) = 0.844 FAR = 30/(30+9870) = 0.0030 Accuracy = 99.65% (misleading) Total = 10,000 | True anomalies = 100 (1%) | Predicted anomalies = 125 Green cells = correct predictions | Red cells = errors Accuracy alone (99.65%) hides the fact that we missed 5 frauds and raised 30 false alarms.

    From the cells above, every metric discussed in this guide is derivable. One observation is important: the accuracy for this model is (95 + 9870) / 10000 = 99.65 percent, which sounds excellent. A constant “always normal” model, however, would score 99.0 percent. The improvement from a real model is therefore only 0.65 percentage points. A comparison of two models on accuracy alone yields almost no useful information.

    The fundamental trade-off in any threshold-based detector is as follows. Lowering the threshold catches more anomalies (TP increases) but also flags more normals (FP increases). Raising the threshold reduces false alarms (FP decreases) but misses more anomalies (FN increases). Every metric in this guide either fixes one threshold and reports performance at that point, or sweeps over all thresholds and summarises the trade-off.

    Threshold-Dependent Metrics: Precision, Recall, F1, FAR, MCC

    These metrics require commitment to a single decision threshold (typically 0.5 for probabilities, or a calibrated value for anomaly scores). Once the threshold is fixed, the four-cell confusion matrix can be computed and the metrics below derived.

    Precision: The Purity of Alerts

    Precision = TP / (TP + FP). The metric answers the question: of everything flagged as anomalous, how many actually were anomalous? In the worked example, Precision = 95/125 = 0.76, which indicates that 76 percent of the alerts were genuine fraud and 24 percent were false alarms.

    Precision matters most in the following contexts:

    • Alert fatigue. If a SOC analyst receives 100 alerts per day of which 90 are incorrect, the analyst will cease to trust the system. The corresponding precision is 0.10.
    • Costly interventions. If acting on an alert involves freezing a customer’s account, the alert must be correct.
    • Limited human review capacity. When only the top 50 cases can be investigated, the investigated cases must be of high quality.

    Recall (Sensitivity, True Positive Rate): The Proportion Caught

    Recall = TP / (TP + FN). The metric answers: of all true anomalies, how many were caught? In the worked example, Recall = 95/100 = 0.95, a 95 percent catch rate.

    Recall matters most in the following contexts:

    • Catastrophic miss costs. Cancer screening, cybersecurity intrusions, and aircraft engine faults are domains in which missing an event is unacceptable.
    • Rare but serious anomalies. When the cost of a false negative greatly exceeds the cost of a false positive.
    • Compliance and regulatory contexts. Anti-money-laundering regulations effectively mandate high recall.

    F1 Score: A Balanced Measure

    F1 = 2·P·R / (P + R) is the harmonic mean of Precision and Recall, constructed so that a low score in either component reduces F1 substantially. In the worked example, F1 = 2 · (0.76)(0.95) / (0.76 + 0.95) = 0.844.

    The harmonic mean is preferred to the arithmetic mean because, for example, Precision = 1.0 and Recall = 0.01 (only one true anomaly flagged out of 100) should not average to 0.505, which would be misleading. The harmonic mean gives 0.0198, which more accurately reflects the model’s poor performance.

    For asymmetric costs, the F-beta measure should be used:

    Fβ = (1 + β2) · P · R / (β2·P + R)

    • β = 1 produces the standard F1, with equal weight on precision and recall.
    • β = 2 produces F2, in which recall is weighted twice as heavily as precision (suitable for medical or security applications).
    • β = 0.5 produces F0.5, in which precision is weighted twice as heavily as recall (suitable for alert-fatigue contexts).

    Specificity (TNR) and False Alarm Rate (FAR/FPR)

    Specificity = TN / (TN + FP) is the fraction of true normals correctly left alone. FAR (= FPR = 1 − Specificity) is the fraction of normals that have been flagged. In the worked example, FAR = 30/9900 = 0.30 percent.

    FAR is the metric that the operations team typically quotes. When 1 million events are processed per day at FAR = 0.5 percent, the result is 5,000 false alarms per day, which is operationally unworkable. Most operational systems target FAR below 0.1 percent or even 0.01 percent and accept the resulting recall.

    False Reject Rate (FRR)

    FRR = FN / (FN + TP) = 1 − Recall. This is biometrics terminology: in face recognition or fingerprint authentication, FRR is the fraction of legitimate users incorrectly rejected. The “False Acceptance Rate” in biometrics is identical to FAR or FPR in this context.

    Matthews Correlation Coefficient (MCC)

    MCC = (TP·TN − FP·FN) / √((TP+FP)(TP+FN)(TN+FP)(TN+FN))

    The range is [−1, +1]. A value of +1 indicates perfect classification, 0 corresponds to random classification, and −1 indicates inverted classification. Unlike F1, MCC uses all four cells of the confusion matrix and remains informative even under severe imbalance. It is particularly useful when a single, balanced number that is not deceived by a majority-class predictor is required.

    Balanced Accuracy

    Balanced Accuracy = (Sensitivity + Specificity) / 2 is the simple average of the per-class accuracies. The “always normal” model achieves 50 percent balanced accuracy regardless of the imbalance. This metric is appropriate when an accuracy-like figure is required that does not reward majority-class prediction.

    Metric Formula Range When to Use
    Precision TP / (TP + FP) [0, 1] Alert fatigue, costly interventions
    Recall (TPR, Sensitivity) TP / (TP + FN) [0, 1] Catastrophic miss costs, security, medical
    F1 2PR / (P + R) [0, 1] Single threshold, balanced trade-off
    Fβ (1+β2)PR / (β2P+R) [0, 1] Asymmetric costs (β>1: recall, β<1: precision)
    Specificity (TNR) TN / (TN + FP) [0, 1] Medical screening (avoid false positives)
    FAR (FPR) FP / (FP + TN) [0, 1] Operations, alert volume control
    FRR (FNR) FN / (FN + TP) [0, 1] Biometrics
    MCC see formula above [−1, 1] Balanced single number for imbalanced data
    Balanced Accuracy (TPR + TNR) / 2 [0, 1] Accuracy-like, imbalance-aware
    AUROC ∫TPR d(FPR) [0, 1] Threshold-free comparison, mild imbalance
    AUPRC (AP) ∫P d(R) [0, 1] Severe imbalance—preferred over AUROC

     

    Threshold-Independent Metrics: AUROC, AUPRC, DET

    The metrics above all assume that a threshold has been chosen. During model development, however, a single number that summarises the model’s quality across all possible thresholds is usually required. Ranking metrics serve this purpose.

    ROC Curve and AUROC

    The Receiver Operating Characteristic (ROC) curve plots TPR (on the y-axis) against FPR (on the x-axis) as the threshold varies. Each point on the curve corresponds to a different decision threshold. The area under this curve, AUROC, has a useful probabilistic interpretation:

    AUROC = P(score(positive) > score(negative))

    If one anomaly and one normal point are drawn at random, AUROC is the probability that the model scores the anomaly higher. A value of 0.5 corresponds to random guessing, 1.0 corresponds to perfect ranking, and 0.95 indicates that 95 percent of randomly chosen pairs are correctly ordered.

    AUROC has useful properties: it is threshold-independent, it is scale-invariant (only the rank order of scores matters), and the random baseline is always exactly 0.5 regardless of class balance. The last property is also its weakness.

    Situations in Which AUROC Misleads

    Consider the following scenario. A dataset of 1 million transactions includes 1,000 fraudulent records (a 0.1 percent rate). The model attains AUROC = 0.97, which sounds impressive. The operational usability is more sobering: at the threshold that produces 1,000 alerts, the model may catch 600 frauds and raise 400 false positives, yielding Precision = 60 percent and Recall = 60 percent. The model still misses 400 frauds, and 40 percent of alerts are false. AUROC = 0.97 has therefore conveyed an impression that the operational reality does not deliver.

    The reason is that AUROC averages TPR over the full FPR range from 0 to 1. In production, however, only the range below approximately 1 percent FPR is of practical interest. Most of the AUROC area is contributed by regions in which the system will never operate. Under severe imbalance, even a sub-1 percent FPR generates substantial numbers of false positives because the negative class is very large.

    Precision-Recall Curve and AUPRC

    The PR curve plots Precision (on the y-axis) against Recall (on the x-axis) as the threshold varies. The area under this curve, AUPRC, also referred to as Average Precision (AP), is considerably more informative for imbalanced data. Saito and Rehmsmeier (2015) demonstrated empirically that PR curves provide a more informative picture than ROC curves when class imbalance is severe.

    The random baseline for AUPRC equals the positive-class fraction. If anomalies constitute 1 percent of the data, a coin-flip detector attains AUPRC of approximately 0.01. Exceeding this baseline by a substantial margin is considerably more demanding than exceeding AUROC’s 0.5 baseline.

    The following figure presents the canonical illustration of the same model evaluated by both curves on a severely imbalanced dataset.

    Same Model, Two Stories, ROC vs PR (1% anomaly rate) ROC Curve AUROC = 0.95 (looks great) False Positive Rate True Positive Rate 0 1 0 1 random model Precision-Recall Curve AUPRC = 0.42 (much less impressive) Recall Precision 0 1 0 1 random = 0.01 model Both panels show the SAME model on the SAME data. AUROC inflates due to the considerable negative class.

    The two curves describe the same model. AUROC = 0.95 suggests a top-tier detector, while AUPRC = 0.42 indicates that the model is adequate but will produce many false positives in production. The PR curve is closer to operational reality.

    Caution: Both AUROC and AUPRC should be reported for imbalanced anomaly detection. Reporting only AUROC for a 0.1 percent anomaly task is, at best, misleading and, at worst, deceptive.

    Detection Error Tradeoff (DET) Curve

    The DET curve is widely used in biometrics and speaker recognition. It plots FAR (on the x-axis) against FRR (on the y-axis), with both axes on a probit (normal-deviate) scale. This transformation stretches the small-error region and facilitates comparison of near-perfect detectors. The Equal Error Rate (EER), the point at which FAR equals FRR, is a single-number summary commonly quoted in this domain.

    When to Use Which Metric: A Decision Framework

    If only one decision aid is to be retained from this article, the following table should be used:

    Situation Recommended Metric(s)
    Severe imbalance (anomalies < 1%) AUPRC (primary), AUROC (secondary)
    Need a single threshold for production F1 (or F-beta if asymmetric costs)
    Operations team cares about alert volume FAR + Recall, or Precision@K
    Cost-sensitive (FN ≫ FP) Recall, F2, cost-weighted score
    Cost-sensitive (FP ≫ FN) Precision, F0.5
    Model selection across architectures AUROC for general comparison; AUPRC if imbalanced
    Reporting to non-technical stakeholders Precision@K, Recall@K, dollar-weighted recall
    Time-series anomaly detection Range-based F1, VUS, NAB Score
    Biometrics / authentication EER, DET curve, FAR @ fixed FRR

     

    Most production teams report a small bundle of metrics: AUPRC, Precision@K, Recall, and FAR. This combination covers model quality, operational alert volume, miss rate, and false-alarm rate, and is sufficient for useful discussion across stakeholder groups.

    Time-Series-Specific Metrics

    Time-series anomaly detection is the domain in which most standard metrics fail. The central issue is that anomalies are typically events, namely contiguous segments of points rather than isolated samples. If a real anomaly lasts from t = 100 to t = 120 (21 timesteps) and a model detects it at t = 103 only, has the model detected the event? Standard point F1 records “1 TP, 20 FN”, which yields a recall of 1/21 = 4.8 percent. Operationally, however, the event has been caught. The label suggests an almost complete miss.

    Several alternative metrics have been proposed. None is fully satisfactory, and the appropriate choice remains a subject of active debate. For a more detailed survey of the models that produce these scores, see the companion guide on time-series anomaly detection models.

    Point-Adjusted (PA) F1

    Proposed in early time-series benchmarks (Xu et al., 2018), Point-Adjusted F1 specifies that if at least one point inside a true anomaly segment is detected, the entire segment is marked as detected. This adjustment substantially addresses the miss-by-one-point problem but it inflates scores in misleading ways. Kim et al. (2022) showed that even random scores can achieve PA-F1 above 0.9 on common benchmarks. PA-F1 should therefore be used with considerable caution and never as the sole metric.

    Range-Based Precision and Recall (Tatbul et al., 2018)

    The seminal paper by Tatbul et al. introduced a parametric framework for range-based recall and precision. Each detection range overlapping a real anomaly range earns partial credit, with adjustable parameters governing the reward for partial overlap (existence, cardinality, or size), the bias toward early or late detection, and the penalty for fragmentation. The framework is principled, configurable, and widely cited, but its parameters require careful selection for each use case.

    NAB Score (Numenta Anomaly Benchmark)

    This metric is designed for streaming detection. Each true anomaly segment is associated with a detection window. Points inside the window earn weighted positive credit (with greater credit for earlier detection), while points outside the window earn weighted negative credit. The result is normalised so that a perfect detector scores 100 and a “no detection” baseline scores 0. NAB is opinionated, since it explicitly rewards early detection, which makes it appropriate for streaming applications and inappropriate for retrospective analysis.

    VUS (Volume Under the Surface, Paparrizos et al., 2022)

    VUS is a range-aware extension of AUROC and AUPRC. Rather than computing area under a 2D curve, VUS computes volume under a 3D surface in which the third dimension is the detection-tolerance buffer. The result is a smooth, parameter-free range-aware metric. VUS-PR is currently among the most defensible single-number summaries for time-series anomaly detection benchmarks.

    Affiliation-Based Metrics (Huet et al., 2022)

    This metric defines a continuous “affiliation” between predicted and true segments based on temporal distance, with statistical normalisation that makes results comparable across datasets. It is more principled than PA-F1 but less widely supported by tooling.

    Metric Range-Aware? Threshold-Free? Notes
    Point F1 No No Penalizes brief detection lag harshly
    Point-Adjusted F1 Partially No Inflates scores; controversial
    Range-Based F1 (Tatbul) Yes No Configurable; needs parameters per use case
    NAB Score Yes No Rewards early detection; for streaming
    VUS-ROC / VUS-PR Yes Yes Modern, parameter-free, recommended
    Affiliation Metrics Yes No Statistical normalization; less tooled

     

    Tip: For new time-series benchmarks, VUS-PR and range-based F1 with documented parameters should be reported. Reliance on PA-F1 alone should be avoided, since recent literature has shown that it can be gamed by random scores.

    Top-K Metrics for Ranking

    In many production environments, the relevant property is not binary classification quality but ranking quality at the top of the list. A SOC analyst reviews the top 50 alerts per shift, and a fraud team escalates the top 100 highest-risk transactions per day. For such contexts, top-K metrics are more appropriate.

    • Precision@K: of the top K most anomalous predictions, the number that correspond to true anomalies. The measure is concrete and operationally meaningful.
    • Recall@K: of all true anomalies, the number that appear in the top K. The measure is useful when a fixed review budget is in place.
    • Mean Average Precision (MAP@K): the average precision computed up to position K, which is sometimes used in ranking contexts.
    • Lift@K: Precision@K divided by the base rate. A lift of 50 indicates that alerts in the top K are 50 times more likely to be anomalies than random samples.

    Top-K metrics require K to be fixed, typically by the available human review capacity. They are less useful for academic comparisons, because different K values produce different rankings, but they are essential for production health monitoring.

    Practical Implementation in Python

    The following section presents the implementations. The discussion proceeds from the confusion matrix to bootstrapped AUROC confidence intervals, providing both scikit-learn shortcuts and from-scratch implementations.

    Setup and Synthetic Data

    import numpy as np
    import matplotlib.pyplot as plt
    from sklearn.metrics import (
        confusion_matrix, precision_score, recall_score, f1_score,
        fbeta_score, roc_auc_score, average_precision_score,
        roc_curve, precision_recall_curve, matthews_corrcoef,
        balanced_accuracy_score
    )
    
    np.random.seed(42)
    
    # 10,000 samples, 1% anomaly rate
    n = 10_000
    anomaly_rate = 0.01
    y_true = np.random.binomial(1, anomaly_rate, size=n)
    
    # Synthetic anomaly score: anomalies tend to score higher
    # Normal points: Beta(2, 5) -> mean ~0.29
    # Anomalies: shifted up by 0.4 (clipped at 1.0)
    y_score = np.random.beta(2, 5, size=n) + y_true * 0.4
    y_score = np.clip(y_score, 0, 1)
    
    print(f"Total samples: {n}")
    print(f"Anomalies: {y_true.sum()} ({y_true.mean()*100:.2f}%)")
    print(f"Score range: [{y_score.min():.3f}, {y_score.max():.3f}]")

    Building the Confusion Matrix from Scratch

    def confusion_from_scratch(y_true, y_pred):
        """Compute (TN, FP, FN, TP) without sklearn."""
        y_true = np.asarray(y_true).astype(int)
        y_pred = np.asarray(y_pred).astype(int)
        TP = int(((y_pred == 1) & (y_true == 1)).sum())
        FP = int(((y_pred == 1) & (y_true == 0)).sum())
        TN = int(((y_pred == 0) & (y_true == 0)).sum())
        FN = int(((y_pred == 0) & (y_true == 1)).sum())
        return TN, FP, FN, TP
    
    threshold = 0.5
    y_pred = (y_score >= threshold).astype(int)
    
    TN, FP, FN, TP = confusion_from_scratch(y_true, y_pred)
    print(f"TP = {TP}, FP = {FP}, TN = {TN}, FN = {FN}")
    
    # Verify against sklearn
    cm = confusion_matrix(y_true, y_pred)
    assert (TN, FP, FN, TP) == (cm[0,0], cm[0,1], cm[1,0], cm[1,1])

    All Threshold-Dependent Metrics, From Scratch

    def metrics_from_confusion(TN, FP, FN, TP):
        """Compute every threshold-dependent metric from a confusion matrix."""
        eps = 1e-12
        precision = TP / (TP + FP + eps)
        recall    = TP / (TP + FN + eps)        # TPR / sensitivity
        specificity = TN / (TN + FP + eps)       # TNR
        fpr = FP / (FP + TN + eps)               # FAR / FPR
        fnr = FN / (FN + TP + eps)               # FRR
        accuracy = (TP + TN) / (TP + TN + FP + FN + eps)
        balanced_acc = (recall + specificity) / 2
        f1 = 2 * precision * recall / (precision + recall + eps)
        f2 = 5 * precision * recall / (4 * precision + recall + eps)
        f05 = 1.25 * precision * recall / (0.25 * precision + recall + eps)
        # MCC
        num = TP * TN - FP * FN
        den = np.sqrt((TP+FP) * (TP+FN) * (TN+FP) * (TN+FN) + eps)
        mcc = num / den
    
        return {
            "Precision": precision, "Recall": recall, "Specificity": specificity,
            "FAR (FPR)": fpr, "FRR (FNR)": fnr, "Accuracy": accuracy,
            "BalancedAcc": balanced_acc, "F1": f1, "F2": f2, "F0.5": f05, "MCC": mcc,
        }
    
    m = metrics_from_confusion(TN, FP, FN, TP)
    for k, v in m.items():
        print(f"  {k:14s} = {v:.4f}")
    
    # Verify with sklearn
    assert abs(m["F1"] - f1_score(y_true, y_pred)) < 1e-6
    assert abs(m["MCC"] - matthews_corrcoef(y_true, y_pred)) < 1e-6
    assert abs(m["BalancedAcc"] - balanced_accuracy_score(y_true, y_pred)) < 1e-6

    AUROC and AUPRC With sklearn

    auroc = roc_auc_score(y_true, y_score)
    auprc = average_precision_score(y_true, y_score)
    print(f"AUROC = {auroc:.4f}  (random baseline = 0.5)")
    print(f"AUPRC = {auprc:.4f}  (random baseline = {y_true.mean():.4f})")

    Plotting ROC and PR Curves

    fpr, tpr, _ = roc_curve(y_true, y_score)
    prec, rec, _ = precision_recall_curve(y_true, y_score)
    
    fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 5))
    
    ax1.plot(fpr, tpr, lw=2, label=f"Model (AUROC = {auroc:.3f})")
    ax1.plot([0, 1], [0, 1], "--", color="gray", label="Random")
    ax1.set_xlabel("False Positive Rate")
    ax1.set_ylabel("True Positive Rate")
    ax1.set_title("ROC Curve")
    ax1.legend()
    ax1.grid(alpha=0.3)
    
    ax2.plot(rec, prec, lw=2, color="crimson", label=f"Model (AUPRC = {auprc:.3f})")
    ax2.axhline(y=y_true.mean(), linestyle="--", color="gray",
                label=f"Random = {y_true.mean():.3f}")
    ax2.set_xlabel("Recall")
    ax2.set_ylabel("Precision")
    ax2.set_title("Precision-Recall Curve")
    ax2.legend()
    ax2.grid(alpha=0.3)
    
    plt.tight_layout()
    plt.savefig("roc_pr_curves.png", dpi=120)

    Finding the Optimal F1 Threshold

    prec, rec, thresholds = precision_recall_curve(y_true, y_score)
    # precision_recall_curve returns one extra point; align with thresholds
    prec_t, rec_t = prec[:-1], rec[:-1]
    
    f1_curve = 2 * prec_t * rec_t / (prec_t + rec_t + 1e-12)
    best_idx = int(np.argmax(f1_curve))
    best_threshold = thresholds[best_idx]
    best_f1 = f1_curve[best_idx]
    
    print(f"Best F1 = {best_f1:.4f} at threshold = {best_threshold:.4f}")
    print(f"  Precision = {prec_t[best_idx]:.4f}")
    print(f"  Recall    = {rec_t[best_idx]:.4f}")

    Sweeping the Threshold

    def threshold_sweep(y_true, y_score, n_thresholds=100):
        """Compute Precision, Recall, F1, FAR for a grid of thresholds."""
        grid = np.linspace(y_score.min(), y_score.max(), n_thresholds)
        rows = []
        for t in grid:
            y_pred = (y_score >= t).astype(int)
            TN, FP, FN, TP = confusion_from_scratch(y_true, y_pred)
            m = metrics_from_confusion(TN, FP, FN, TP)
            rows.append([t, m["Precision"], m["Recall"], m["F1"], m["FAR (FPR)"]])
        return np.asarray(rows)
    
    sweep = threshold_sweep(y_true, y_score, 200)
    t_grid, prec_g, rec_g, f1_g, far_g = sweep.T
    
    plt.figure(figsize=(9, 5))
    plt.plot(t_grid, prec_g, color="#e74c3c", label="Precision")
    plt.plot(t_grid, rec_g,  color="#3498db", label="Recall")
    plt.plot(t_grid, f1_g,   color="#27ae60", label="F1")
    plt.plot(t_grid, far_g,  color="#f39c12", label="FAR")
    plt.axvline(best_threshold, linestyle="--", color="black", alpha=0.6,
                label=f"Best F1 t={best_threshold:.3f}")
    plt.xlabel("Threshold")
    plt.ylabel("Metric value")
    plt.title("Metric vs Threshold (1% anomaly rate)")
    plt.legend()
    plt.grid(alpha=0.3)
    plt.tight_layout()

    Threshold Trade-off, Precision, Recall, F1, FAR Decision Threshold Metric Value 0.0 0.2 0.4 0.6 0.8 1.0 0.0 0.2 0.4 0.6 0.8 1.0 Recall Precision F1 FAR Best F1 t* ≈ 0.55

    Cost-Weighted Metric

    def cost_weighted_score(y_true, y_pred, c_fp=1.0, c_fn=10.0):
        """Lower is better. Useful when FN costs ~10x more than FP."""
        TN, FP, FN, TP = confusion_from_scratch(y_true, y_pred)
        return c_fp * FP + c_fn * FN
    
    def best_threshold_by_cost(y_true, y_score, c_fp=1.0, c_fn=10.0, n=200):
        grid = np.linspace(y_score.min(), y_score.max(), n)
        costs = []
        for t in grid:
            y_pred = (y_score >= t).astype(int)
            costs.append(cost_weighted_score(y_true, y_pred, c_fp, c_fn))
        best = int(np.argmin(costs))
        return grid[best], costs[best]
    
    t_cost, c_cost = best_threshold_by_cost(y_true, y_score, c_fp=1, c_fn=20)
    print(f"Cost-optimal threshold = {t_cost:.4f}, total cost = {c_cost:.0f}")

    Bootstrap Confidence Intervals: An Often Overlooked Step

    Single-number reports without uncertainty estimates are problematic. A 1,000-sample test set containing 10 positives can produce widely varying AUPRC values across reasonable bootstrap resamples. The bootstrap is the standard method for attaching a confidence interval. The reason that averaging across many resamples produces a stable estimate derives from the Central Limit Theorem.

    def bootstrap_ci(y_true, y_score, metric_fn, n_boot=1000, alpha=0.05, seed=0):
        """Bootstrap percentile CI for any score-based metric."""
        rng = np.random.default_rng(seed)
        n = len(y_true)
        scores = []
        for _ in range(n_boot):
            idx = rng.integers(0, n, size=n)
            y_t, y_s = y_true[idx], y_score[idx]
            if y_t.sum() == 0 or y_t.sum() == n:
                continue  # degenerate resample
            scores.append(metric_fn(y_t, y_s))
        scores = np.asarray(scores)
        lo = np.quantile(scores, alpha/2)
        hi = np.quantile(scores, 1 - alpha/2)
        return float(np.mean(scores)), (float(lo), float(hi))
    
    mean_auroc, ci_auroc = bootstrap_ci(y_true, y_score, roc_auc_score, n_boot=500)
    mean_auprc, ci_auprc = bootstrap_ci(y_true, y_score, average_precision_score, n_boot=500)
    
    print(f"AUROC = {mean_auroc:.4f}  95% CI [{ci_auroc[0]:.4f}, {ci_auroc[1]:.4f}]")
    print(f"AUPRC = {mean_auprc:.4f}  95% CI [{ci_auprc[0]:.4f}, {ci_auprc[1]:.4f}]")

    Time-Series PA-F1 Implementation

    def get_event_segments(y):
        """Return list of (start, end_inclusive) for runs of 1s."""
        y = np.asarray(y).astype(int)
        if len(y) == 0:
            return []
        diff = np.diff(np.concatenate(([0], y, [0])))
        starts = np.where(diff == 1)[0]
        ends   = np.where(diff == -1)[0] - 1
        return list(zip(starts.tolist(), ends.tolist()))
    
    def point_adjusted_predictions(y_true, y_pred):
        """Apply Point-Adjusted (PA) protocol: if any point inside a true
        anomaly segment is detected, flag the entire segment as detected."""
        y_pred = y_pred.copy().astype(int)
        for s, e in get_event_segments(y_true):
            if y_pred[s:e+1].any():
                y_pred[s:e+1] = 1
        return y_pred
    
    # Worked example
    y_t = np.array([0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 0, 0])
    y_p = np.array([0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0])
    
    print("Raw point F1     =", round(f1_score(y_t, y_p), 4))
    y_pa = point_adjusted_predictions(y_t, y_p)
    print("PA-adjusted pred =", y_pa.tolist())
    print("PA-F1            =", round(f1_score(y_t, y_pa), 4))

    In this example the raw point F1 is approximately 0.18 (one TP, two FN inside the first event, one FP outside, and no detection on the second event). After point adjustment, the entire first event is marked as "detected" because one point inside it was flagged, and recall increases substantially. This is the inflation effect that Kim et al. (2022) identified: PA-F1 can appear impressive even when the underlying detection is weak. For range-aware alternatives, the VUS package or the Tatbul range-based implementation in the tsad Python library is recommended.

    Selecting the Threshold for Production

    Once the model has been trained and AUROC and AUPRC are acceptable, the question is which threshold to deploy. The five common strategies are presented below, ordered from the simplest to the most sophisticated.

    Maximise F1 on the Validation Set

    Thresholds are swept on a held-out validation set, and the one with the highest F1 is selected. The procedure is simple, defensible, and yields a balanced precision and recall point. Important caveat: the threshold should never be selected on the test set, as this constitutes data leakage. Validation data must always be reserved for hyperparameter and threshold selection.

    Fixed FAR Budget

    This is the operations-driven approach. For example, if the team can handle 100 alerts per day across 1 million events per day, FAR must be at most 0.01 percent. The threshold corresponding to FAR = 0.0001 on the validation set is selected, and the corresponding recall is reported. Most cybersecurity and network monitoring systems in production are tuned in this way.

    def threshold_for_far_budget(y_true, y_score, far_budget=0.001):
        """Largest recall achievable subject to FAR ≤ far_budget."""
        fpr, tpr, thr = roc_curve(y_true, y_score)
        feasible = fpr <= far_budget
        if not feasible.any():
            return None, 0.0, 0.0
        idx = np.argmax(tpr * feasible)
        return float(thr[idx]), float(tpr[idx]), float(fpr[idx])
    
    t, r, f = threshold_for_far_budget(y_true, y_score, far_budget=0.005)
    print(f"Threshold = {t:.4f}, Recall = {r:.4f} at FAR = {f:.4f}")

    Cost-Weighted Optimisation

    If the dollar cost of a false positive (such as analyst time and customer impact) and a false negative (such as missed fraud value) can be quantified, the threshold that minimises CFP·FP + CFN·FN should be selected. This is the most defensible approach when the asymmetry is well understood.

    Top-K Selection

    This approach forgoes the threshold entirely. Scores are ranked and the top K cases are selected. It is appropriate when human review capacity is the binding constraint and alert volume per period is fixed.

    Sliding or Contextual Threshold

    Time-of-day, day-of-week, or per-segment thresholds may be used. A retail fraud detector might use a threshold of 0.6 on weekday afternoons and 0.4 on holiday weekends. Implementation typically involves a small lookup table or a contextual model that outputs both score and threshold.

    Caution: Thresholds drift. As the data distribution shifts because of seasonal effects and the evolution of fraud patterns, the threshold that maximised F1 in January may produce twice the alert volume in June. Monthly threshold retuning should be scheduled, and precision and FAR should be monitored continuously.

    Common Pitfalls to Avoid

    The most frequently encountered errors across anomaly detection projects in fraud, manufacturing, security, and healthcare are listed below.

    • Reporting AUROC without AUPRC on imbalanced data. AUROC = 0.99 with 0.1 percent positives often corresponds to AUPRC = 0.40. Both should always be reported.
    • Reporting accuracy. For anomaly detection, accuracy is almost always uninformative. The "always negative" baseline outperforms most real models on accuracy.
    • Selecting the threshold on the test set. Tuning should be performed on the validation set, and evaluation on the test set. Maximising F1 across thresholds on the same test set constitutes overfitting.
    • Not using stratified k-fold. With 1 percent positives in 1,000 samples, a random fold may contain zero positives in the validation split. StratifiedKFold should be used.
    • Ignoring confidence intervals. A reported AUPRC of 0.42 ± 0.15 (95 percent CI) is qualitatively different from 0.42 ± 0.02. Bootstrap intervals should be computed and reported.
    • Comparing models on different test sets. This is not a like-for-like comparison. The same fixed test set must be used across all model comparisons.
    • Using point F1 for time series. A single-step detection lag reduces the score substantially. Range-based metrics or VUS should be used instead.
    • Confusion between microaverage and macroaverage in multi-class anomaly settings. Microaverage favours common classes; macroaverage equalises them. The choice must be made deliberately and documented.
    • Treating PA-F1 as a definitive measure. It can be inflated by random noise. If used, it should be reported alongside non-PA metrics.
    • Optimising offline metrics that do not translate to deployment. When the business operates on alert-volume budgets, the metric that respects that constraint should be optimised, rather than F1 alone.

    Real-World Reporting Templates by Domain

    Different domains converge on different metric stacks. The following recommendations are distilled from observed production systems. For more detailed treatment of the underlying anomaly detection methods, the companion guides on Deep SVDD and One-Class SVM may be consulted.

    Domain Recommended Metric Stack Why
    Fraud detection AUPRC, Precision@K, Recall, $-weighted recall Severe imbalance + dollar asymmetry
    Network intrusion AUROC, Precision, FAR @ fixed Recall Operations cares about alert volume
    Medical screening Sensitivity (Recall), Specificity, AUROC Regulatory norms; symmetric reporting
    Industrial sensor Range-based F1, Precision@K, time-to-detect Time-series events; early detection valued
    Server monitoring Precision@K, MTTD, false-alert-per-day Streaming context, on-call workload
    Biometrics / authentication EER, DET curve, FAR @ fixed FRR Field-standard reporting
    Anti-money-laundering Recall + Precision@K, regulatory alert quality Compliance sets minimum recall
    Manufacturing defect Recall, Precision, cost-weighted score Defect cost vs over-inspection cost

     

    If the model is built on top of transfer learning or fine-tuning approaches, the same metric framework applies, although particular caution should be taken with confidence intervals, since pre-training source-target distribution gaps can render small test sets highly noisy.

    Key Takeaway: A robust default reporting set for any anomaly detection project comprises AUPRC, Precision@K, Recall, and FAR, each reported with bootstrap 95 percent confidence intervals and a documented threshold. This combination covers model quality, top-of-list usefulness, miss rate, and operational alert volume.

    Frequently Asked Questions

    Why isn't accuracy a good metric for anomaly detection?

    Because anomalies are rare. If 99% of your data is normal, a "predict normal always" model achieves 99% accuracy without learning anything. Real models barely lift accuracy by a few tenths of a percentage point, so accuracy can't distinguish good models from useless ones. Use AUPRC, F1, or Precision@K instead.

    AUROC vs AUPRC—when should I use which?

    For mild imbalance (positives 5–50%), AUROC and AUPRC tell roughly similar stories, and AUROC is fine. For severe imbalance (positives below 1%), AUROC inflates because most of its area comes from FPR regions you'll never operate in. AUPRC is more honest because its random baseline equals the positive class fraction. Best practice: report both, but rely on AUPRC for imbalanced anomaly detection.

    How do I pick a threshold for production?

    Pick the strategy that matches your business constraint. If your team has a fixed alert-review budget, use top-K or fixed-FAR. If you can quantify costs, optimize C_FP·FP + C_FN·FN. If neither, maximize F1 on a held-out validation set. Always select the threshold on validation, evaluate on test, and re-tune monthly as data shifts.

    What's the difference between FAR and FPR?

    None — they are the same metric: FP / (FP + TN). "False Alarm Rate" is the operations and biometrics term; "False Positive Rate" is the statistical term. Some literature also uses "False Acceptance Rate" (biometrics, identical concept) or "Type I Error rate" (classical statistics).

    Are time-series anomaly detection metrics different?

    Yes. Anomalies in time series are typically contiguous events, not isolated points, so naive point-wise F1 over-penalises brief detection lag. Use range-based metrics (Tatbul et al., 2018), VUS-PR (Paparrizos et al., 2022), or NAB Score for streaming. Reliance on Point-Adjusted F1 alone should be avoided, since recent work has shown that it can be gamed by random noise.

    References and Further Reading

    External References:

    • scikit-learn metrics documentation—https://scikit-learn.org/stable/modules/model_evaluation.html
    • Saito, T. & Rehmsmeier, M. (2015). "The Precision-Recall Plot Is More Informative than the ROC Plot When Evaluating Binary Classifiers on Imbalanced Datasets." PLOS ONE.
    • Tatbul, N., Lee, T. J., Zdonik, S., Alam, M., & Gottschlich, J. (2018). "Precision and Recall for Time Series." NeurIPS.
    • Paparrizos, J., Boniol, P., Palpanas, T., Tsay, R., Elmore, A., & Franklin, M. (2022). "Volume Under the Surface: A New Accuracy Evaluation Measure for Time-Series Anomaly Detection." VLDB.
    • Numenta Anomaly Benchmark (NAB),https://github.com/numenta/NAB
    • Huet, A., Navarro, J. M., & Rossi, D. (2022). "Local Evaluation of Time Series Anomaly Detection Algorithms." KDD.
    • Kim, S. et al. (2022). "Towards a Rigorous Evaluation of Time-Series Anomaly Detection." AAAI.

    This article is for informational purposes only and does not constitute investment, security, or medical advice. Always validate metrics against your specific operational context.