Skip to Content
OpenETL
Pipelines

Pipelines as code

How pipelines are written as YAML, planned and applied with the CLI, versioned, and run day to day.

A pipeline is a YAML document that you can version in git. Use openetl plan to review changes and stream delivery guarantees. Use openetl apply to create or update the resources. This guide explains the workflow, resource documents and common pipeline settings.

If you haven't yet, run the quickstart first: this page builds on its faker source and local-postgres destination. Concepts explains the words used here.

Your first pipeline then copies tables from your own Postgres database into another, step by step.

The documents

Everything you apply is a document with the same four parts:

apiVersion: openetl.io/v1alpha1
kind: Destination
metadata:
  name: local-postgres
spec:
  connector: postgres
  config:
    dsn: { secret: local-postgres-dsn }
  • apiVersion is openetl.io/v1alpha1, except for a Connector's, which is openetl.io/v1alpha2.
  • kind says what the document defines.
  • metadata.name is lowercase letters, digits and hyphens, at most 63 characters. metadata.workspace is optional: an installation has one workspace, default.
  • spec is the definition itself.

A file can hold several documents, separated by ---, and -f can be given more than once. Documents refer to each other by name, so a pipeline's source.ref is the name of a Source.

KindWhat it definesCovered in
SourceA system to read from: a connector and its configThis page
DestinationA system to write to: a connector and its configThis page
PipelineWhat to copy from a source to a destination, and howThis page
DbtProjectA dbt project's files, to run after a pipeline's syncsIntegrate
SparkCluster, FlinkClusterYour own Spark or Flink cluster, for transforms and stepsIntegrate
WebhookWhere platform events, such as a failed sync, are sentIntegrate
Connector, DeclarativeSourceA version of a connector, or of a source written in YAMLBuild a connector
ConnectorSourceA registry or repository whose connector packages the console listsBuild a connector

A Source or Destination specifies a connector and its config. The connector's documentation defines the configuration fields. openetl connector spec TARGET prints the connector's specification, including its configuration JSON Schema.

An unversioned name such as postgres can select a newer installed version each time you apply the connection. The plan warns about this. postgres@1 restricts selection to major version 1. Pin an exact version or digest when the deployment requires a specific release.

Choose and manage connectors covers installing official, community and private packages, selecting versions, and upgrading them independently of OpenETL.

Secrets

A config never holds a password. It names a secret instead, as { secret: NAME }:

config:
  dsn: { secret: local-postgres-dsn }

Store secrets with the CLI. set reads a value from standard input or --from-file. With --ref, it stores a reference to a cloud secret store. Runners resolve that reference with their cloud identity:

echo postgres://postgres:quickstart@quickstart-postgres:5432/postgres | openetl secret set local-postgres-dsn
openetl secret list
openetl secret delete local-postgres-dsn

list shows current resources that use each secret. delete refuses a secret used by a current source, destination, webhook, Spark cluster, Flink cluster or pipeline. Check private connector sources separately. Manage connection secrets covers cloud references, credential replacement and deletion.

set drops one line ending from the end of a value it reads, and runners do the same with a value in a cloud store that's text, so a file saved by an editor or written by echo works as is. A field of a JSON value is used exactly as it is.

Plan, then apply

openetl plan -f FILE sends the documents to the API, which checks them and works out what applying them would do, without changing anything. Here's a second pipeline from the quickstart's faker source, into Parquet files on the runner's disk with the built-in parquet connector, at 06:00 every day:

apiVersion: openetl.io/v1alpha1
kind: Destination
metadata:
  name: local-parquet
spec:
  connector: parquet
  config:
    url: file:///tmp/openetl-parquet
---
apiVersion: openetl.io/v1alpha1
kind: Pipeline
metadata:
  name: faker-to-parquet
spec:
  schedule: { cron: "0 6 * * *" }
  source: { ref: faker }
  destination: { ref: local-parquet }
openetl plan -f faker-to-parquet.yaml
would be created workspaces/default/destinations/local-parquet (version d10c3e1925e9)
would be created workspaces/default/pipelines/faker-to-parquet (version known after apply)

workspaces/default/pipelines/faker-to-parquet (version known after apply)
  job size: s (1 vCPU, 2 GiB), the default, in pool default
  streams:
    users      append  exactly_once  exact replay
    purchases  append  exactly_once  exact replay

The plan has two parts:

  • A line per document shows its planned state and version. Pipelines affected by a source or destination change also get new versions, even if the file does not define them. Their lines end with "which uses what changed".
  • A section per pipeline, which can have:
    • changes: each field that changes, as field: before -> after.
    • job size: how much CPU and memory its jobs get, what set that, and the pool of runners it runs in.
    • streams: each stream's write mode, its delivery guarantee, and the reason for that guarantee.
    • problems: what stops the pipeline from running. plan exits with an error, and apply refuses the whole file.
    • warnings: what's allowed but worth a second look.
    • notes: what the plan couldn't check, and why.

The streams section requires two sources of information:

  • The source catalog. openetl apply and openetl discover NAME refresh it. Until discovery completes, the plan checks only delivery settings.
  • Destination capabilities. The connector package declares them. For a local executable without a package, the plan explains that the runner checks guarantees before each sync.

A plan with problems looks like this one, for the same pipeline made continuous:

workspaces/default/pipelines/faker-to-parquet (version known after apply)
  job size: s (1 vCPU, 2 GiB), the default, in pool default
  streams:
    users      append  exactly_once  exact replay
    purchases  append  exactly_once  exact replay
  problems:
    stream "users" is read with full_refresh, whose reads end, and a continuous pipeline needs streams read with cdc or stream
    stream "purchases" is read with full_refresh, whose reads end, and a continuous pipeline needs streams read with cdc or stream
openetl: the plan has problems, so apply would refuse it

When the plan is what you want, apply the same files:

openetl apply -f faker-to-parquet.yaml

An apply stores all documents together or stores none. Its output identifies each resource as "created", "updated" or "unchanged", followed by warnings.

After the apply, a runner checks each source and destination that changed or that a changed pipeline uses. The runner also discovers those sources. The output shows checked with their names. A failed check only gives a warning: the applied documents stay, and a new pipeline still starts. To skip the checks, use --no-check. To run the checks for a pipeline at any time, use openetl check pipeline NAME.

A new scheduled pipeline starts its first sync immediately. An updated pipeline waits for its schedule or a manual openetl sync.

openetl apply --dry-run performs apply validation without storing changes. It does not print the plan; use openetl plan to review the planned changes.

Versions

Each applied document is stored as an immutable version identified by its content hash. A pipeline version pins its source, destination and dbt project versions. Changing a source therefore creates a new version of every pipeline that uses it. Each sync runs one version.

openetl versions faker-to-postgres
VERSION                                                           CURRENT  CREATED              PROVENANCE
edab40da6d76c1aba235c7cd81c5618db8af2206c759539c5baea80a584560b3           2026-11-08 12:30:15  cli:examples/faker-to-postgres.yaml#3
9e052e7e6881672dbdcfcc4356f04f592bdfbd9777622e2d2a877eb1530db134  *        2026-11-08 12:28:23  cli:examples/faker-to-postgres.yaml#3

Provenance identifies the origin of a version. Here, it is the third document in a file applied with the CLI. Versions are identified by content. To restore an earlier pipeline definition, revert its git commit and apply the file. This does not undo changes already written to the destination.

The pipeline spec at a glance

Here is a pipeline that sets more than the defaults:

apiVersion: openetl.io/v1alpha1
kind: Pipeline
metadata:
  name: shop-to-warehouse
spec:
  schedule: { cron: "CRON_TZ=Europe/Berlin 0 6 * * *", jitter: 5m }
  source:
    ref: shop-db
    streams:
      - name: orders
        sync: incremental
        cursor: updated_at
      - name: customers
        sync: cdc
        columns:
          balance: { type: string }
  destination:
    ref: warehouse
    namespace: shop
    write: merge
    deletes: hard
  schemaPolicy: allow_columns
  delivery:
    require: effectively_once
    commit: { interval: 2m, maxBytes: 512MB, scope: stream }
  resources: { size: m }
  heal: { mode: suggest }

Enum values are lowercase (merge), durations are Go durations (90s, 15m), and sizes in bytes take units (512MB, 64MiB). The tables below give each field in a line; Pipeline in the reference has every field.

Running

FieldWhat it doesUnset
modescheduled or continuousscheduled
schedule.everyStart a sync this often, such as 1hOne of every and cron is required in a scheduled pipeline
schedule.cronStart a sync at the times a cron expression gives, in UTC unless it starts with CRON_TZ=, such as CRON_TZ=Europe/Berlin 0 6 * * *
schedule.jitterStart each sync up to this much later, at random, so pipelines on the same schedule don't all start at onceNo delay
resources.sizeHow much CPU and memory each job gets: auto, xs (0.5 vCPU, 1 GiB), s (1 vCPU, 2 GiB), m (2 vCPUs, 4 GiB), l (4 vCPUs, 8 GiB), xl (8 vCPUs, 16 GiB), or { cpu: 3, memory: 6Gi }auto: from the connectors' hints, then the job's measured peaks
resources.min, resources.maxThe smallest and largest size auto may choose; max is also what a retry after running out of memory may grow tomax: xl for auto, otherwise the size itself
resources.finishWithinScheduled, with an automatic size: run as many lanes as the last sync's volume needs to finish within this long
resources.poolWhich pool of runners runs the jobsdefault
dataPlaneWhich data plane's runners run the jobsThe server's, default
heal.modeWhat OpenETL does about the pipeline's incidents: off opens them and does nothing else; suggest also proposes actions, such as pausing the pipeline or a stream, or syncing less often; auto_safe takes them; auto does the same as auto_safeauto_safe

Schedules covers when syncs start: intervals, cron time zones, jitter, a sync that runs long, pausing, and time OpenETL wasn't running.

Source and streams

FieldWhat it doesUnset
source.refThe Source to readRequired
source.streamsThe streams to copy, each by name as openetl discover lists it, with the settings belowEvery stream the source offers
syncHow the stream is read: full_refresh, incremental, cdc or streamThe first way the source says it reads the stream; in a continuous pipeline, the first whose reads don't end
cursorThe column an incremental read goes by, such as updated_atThe source's cursor for the stream, if it names one
columns.NAME.typeFor a column the source declares as a decimal, deliver it as string (the source's text) or float64 (a double, which can lose digits) insteadThe decimal
timestampsmicrosecond, or nanosecond for a destination that keeps themmicrosecond; finer values are truncated
requireThis stream's lowest acceptable delivery guarantee, overriding delivery.requireThe pipeline's
collapseFor a stream without a primary key: rows with the same content become one rowOn for a stream that may be read twice and requires more than at least once
quarantineWhat a value that doesn't fit its type holds back: its row, or only its cell (a keyed stream into a destination that patches)row
held, heldValueWhat a held cell shows: null, a value given in heldValue, or last, the destination's value (merge only); also under columns.NAME for one columnnull
groupsLists of columns held together: holding one holds the rest
typeChangesrefuse or allow, overriding destination.typeChanges for this streamThe destination's

Destination

FieldWhat it doesUnset
destination.refThe Destination to write toRequired
destination.namespaceThe schema or dataset to write into
destination.tablesStreams' names in the destination, by stream, such as orders: fct_orders, exactly as written: a table, or what the destination has instead, such as a topicEach stream's own name, through destination.naming
destination.writeappend, overwrite or mergeWhere the destination can: overwrite for a full refresh, merge for a keyed stream read with CDC, append otherwise
destination.deletesHow merge applies a delete: soft keeps the row with _openetl_deleted set to true, hard removes itsoft
destination.namingnormalized: lowercase, with anything outside a-z, 0-9 and _ replaced; source: names as the source has them, quotednormalized
destination.typeChangesWhether the destination may change a column's type itself when the stream's widens: refuse or allowrefuse

Schema and delivery

FieldWhat it doesUnset
schemaPolicyWhat a change to a stream's schema does. allow_all adds columns and widens types, and sends a type change that isn't a widening to a new column; allow_columns adds columns and widens types, but holds other type changes or fails when they cannot be held; block holds changes or fails the stream; ignore_new drops new columns and quarantines values that don't fitallow_all
delivery.requireThe lowest guarantee any stream may have: at_least_once, effectively_once or exactly_onceexactly_once
delivery.commit.intervalHow often to commit what's been read5 minutes in a scheduled pipeline; in a continuous one, the destination's own interval, or 10 seconds
delivery.commit.maxBytesCommit once this much has been read since the last commit256MB, which grows when commits can't keep up
delivery.commit.scopeCommit every stream together (pipeline) or each on its own (stream)pipeline where the destination can, otherwise stream
delivery.resumePointTimeoutHow long a commit waits for a point the source can resume from exactly, before committing without one where the stream keeps its guarantee anyway; none waits forever1m
types.infinityWhat becomes of infinite dates and timestamps: clamp to the type's largest or smallest value, or quarantineclamp

After and between

FieldWhat it does
postSyncSteps that run, in order, after each sync's data is committed: dbt (a DbtProject, with command build, run or test, select, exclude and schema) or spark (your own Spark application). A step that fails fails the sync. Scheduled pipelines only; see examples/faker-to-postgres-dbt.yaml
transformYour own code between the source and the destination: spark in a scheduled pipeline, flink in a continuous one
sparkAccess, flinkAccessHow the pipeline's Spark or Flink code reaches its part of the data plane's storage; unset, the cluster's own identity

Integrate covers dbt, Spark and Flink.

Handle schema changes and holds walks through a new column, a refused type change, and repairing and releasing the hold.

Continuous pipelines

A continuous pipeline has mode: continuous and no schedule. Its job keeps reading and commits at intervals. The default is 10 seconds unless the destination or delivery.commit.interval specifies another value:

apiVersion: openetl.io/v1alpha1
kind: Pipeline
metadata:
  name: orders-live
spec:
  mode: continuous
  source:
    ref: shop-db
    streams:
      - name: orders
        sync: cdc
  destination: { ref: warehouse, namespace: shop }

Its streams must be read in a way that never ends: cdc from a database, or stream from an event stream such as a topic. The plan fails a stream read any other way. A continuous pipeline has no syncs, so it has no postSync steps, and its transform, if any, is Flink. Pausing it stops its job; resuming starts it again from its last checkpoint.

Delivery guarantees in practice

The default requirement is exactly_once. Set delivery.require to change it for the pipeline, or a stream's require to change it for that stream. OpenETL refuses to run a pipeline if any stream falls below its requirement. The plan lists each stream's guarantee and reason.

Deliver-and-acknowledge destinations can repeat a record after a retry. They provide at-least-once delivery. A pipeline using such a destination must accept that guarantee:

delivery:
  require: at_least_once

Under at_least_once, repeated changes remain as duplicate rows. For example, an incremental read includes rows at the previous highest cursor value again. With a higher guarantee, a capable destination removes duplicates during apply. With at_least_once, it writes them again. Recovery after a failure can also produce duplicates.

When reading a stream delivered at least once, keep one row per _openetl_id. This ID combines the stream, primary key and _openetl_seq. Without a primary key, it uses the row's whole content instead. Repeated reads of the same version produce the same ID. In Postgres:

select distinct on (_openetl_id) *
from faker.users
order by _openetl_id;

For the latest version of each row of an appended stream, keep the highest _openetl_seq for each primary key instead:

select distinct on (id) *
from faker.users
order by id, _openetl_seq desc;

Delivery guarantees explains guarantee selection, plan reasons and streams without primary keys. It also shows how to read data delivered at least once.

Day to day

CommandWhat it does
openetl plan -f FILEShow what applying the documents would change, and each pipeline's plan
openetl apply -f FILECreate or update the documents' resources, then check what changed on a runner; --no-check skips that
openetl sync NAMEStart a sync of a scheduled pipeline now; it fails if the pipeline is paused or a sync is running
openetl pause NAMEStop a pipeline's scheduled syncs; a sync that's running carries on. A continuous pipeline's job stops. --stream STREAM stops only that stream, which syncs skip (Pause one stream)
openetl resume NAMEStart its scheduled syncs, or its job, again; --stream STREAM resumes a stream paused on its own
openetl reset NAMERead its streams again from the start on the next sync or run; --stream picks streams. See Reading from the start again
openetl rewind --yes NAMELet it restart from the checkpoint before its latest, if the latest can't be read, delivering that epoch again
openetl overrides NAMEList its paused streams, and heal's overrides of its spec (When heal slows syncs down)
openetl overrides remove NAME FIELDGive the spec back its say in a field heal overrides: schedule.every or source.rate_limit_scale
openetl syncs NAMEList its syncs, newest first, with how long each took, the rows it copied, its version and any error
openetl logs NAMEPrint the latest sync's log; -f follows a running one, and --sync ID picks another sync
openetl versions NAMEList its versions, newest first
openetl rescale --size SIZE NAMESize its jobs until its next version; a running job restarts at a checkpoint. --size auto gives the spec back its say
openetl check source NAME, openetl check destination NAMECheck that a runner can reach the source or destination with its config
openetl check pipeline NAMECheck its source and destination, refresh the source's catalog and print its plan; fails if any of that finds a problem
openetl discover NAMEList a source's streams and their columns, and refresh the catalog plans read
openetl holds NAMEList the columns and cells the pipeline holds back, why, and the ways to clear each
openetl holds release NAME STREAM COLUMNPatch a hold's values in on the pipeline's next run; --new-version, before the names, sends a column held whole to a new column, COLUMN__v2, instead
openetl holds discard NAME STREAM COLUMNDrop a hold's values; the column stays as the destination has it

Flags go before the names, and every command takes --workspace. openetl COMMAND -h lists a command's flags.

The console shows the same pipelines, syncs, versions and holds; Use the console walks through it.

If a pipeline waits, fails repeatedly or falls behind, start with Troubleshoot a pipeline.

On this page