Migrating from Airbyte to Sling in 30 Minutes

Slinger avatar
Slinger
Cover for Migrating from Airbyte to Sling in 30 Minutes

Migrating from Airbyte to Sling in 30 Minutes

Most teams do not leave Airbyte because it stopped working. They leave because the ratio stopped making sense. You are running a Kubernetes cluster and a Temporal namespace to keep three Postgres tables in sync with Snowflake.

If that is where you are, the migration is smaller than you expect. A database replication in Airbyte is a stream list, a cursor field, a primary key, and a destination namespace. In Sling it is the same four things in a YAML file. Below is the mapping, plus a real cutover run against Postgres and Snowflake with the row counts to check it.

One thing up front, because it saves people a wasted weekend: this is not a rip-and-replace argument. If you have forty long-tail SaaS API sources in Airbyte, keep them there. That catalog is genuinely larger than ours. The connections worth moving are the database and file-system ones, where the infrastructure is doing the least work for you.


What you are actually translating

Airbyte splits sync behavior across two fields in the protocol. sync_mode describes how the source is read and has exactly two values, full_refresh and incremental. destination_sync_mode describes how the destination is written and has three: overwrite, append, and append_dedup.

The UI and the Terraform provider collapse those into a single combined enum, which is where most of the confusion comes from. In terraform-provider-airbyte you will see strings like incremental_deduped_history and full_refresh_overwrite. Same two axes, flattened into one name. Know which layer you are reading from before you start translating.

Here is how the combinations land in Sling:

Airbyte (combined enum)Airbyte protocol pairSling
full_refresh_overwritefull_refresh + overwritemode: full-refresh
full_refresh_appendfull_refresh + appendmode: full-refresh-append
incremental_appendincremental + appendmode: incremental with update_key, no primary_key
incremental_deduped_historyincremental + append_dedupmode: incremental with update_key and primary_key

The last row is the one people get wrong. In Sling, dropping the primary_key does not raise an error. It silently turns an upsert into an append, and you find out weeks later when the row counts drift. If your Airbyte connection used a deduped mode, both keys are mandatory on the Sling side.

The primary_key shape gotcha

Airbyte’s cursor_field is an array of strings. Its primary_key is an array of arrays. The asymmetry is deliberate, since each entry is a path into a possibly-nested JSON record, so a composite key looks like [["tenant_id"], ["id"]] rather than ["tenant_id", "id"].

If you script this translation, flatten one level. Sling takes a plain list:

primary_key: [tenant_id, id]

Reading your existing config

You do not need to transcribe from the UI. Pull the connection over the API:

curl -s -H "Authorization: Bearer $AIRBYTE_TOKEN" \
  "https://api.airbyte.com/v1/connections/$CONNECTION_ID"

If you manage Airbyte with Terraform, the airbyte_connection resource already has everything in version control: configurations.streams for the stream list, then cursor_field, primary_key, and sync_mode per stream.

Pay attention to three fields that decide where tables land, because they map onto Sling’s object rather than onto a mode. namespace_definition (source, destination, or custom_format) and namespace_format control the destination schema; prefix gets prepended to each stream name on write. If your Airbyte tables are called raw_orders because someone set a raw_ prefix three years ago, that prefix lives in Sling’s object template.

The replication file

Here is the file from the run below. Three Postgres tables into Snowflake, deduped incremental, which is the Airbyte equivalent of incremental_deduped_history on all three streams.

source: PG_SLING_DEV
target: SNOWFLAKE

defaults:
  object: demo_airbyte_migration.{stream_table}
  mode: incremental
  primary_key: [id]
  update_key: updated_at

streams:
  demo_airbyte_migration.customers:
    primary_key: [customer_id]

  demo_airbyte_migration.products:
    primary_key: [product_id]

  demo_airbyte_migration.orders:
    primary_key: [order_id]
    update_key: ordered_at

The defaults block does the work that Airbyte’s connection-level settings do. Per-stream overrides handle the streams whose cursor or key differs, so orders uses ordered_at instead of updated_at. The {stream_table} runtime variable in object is where a namespace_format or prefix translates to.

The file also does not carry any scheduler configuration, resource requests, or connector image tags, because Sling does not schedule anything. Whatever already runs your other jobs runs this one: cron, Airflow, a CI runner.

Running it

The first run against an empty target loads everything:

$ sling run -r replication.yaml

...
INF [1 / 3] running stream demo_airbyte_migration.customers
INF connecting to source database (postgres)
INF connecting to target database (snowflake)
INF getting checkpoint value (updated_at)
INF writing to target database [mode: incremental]
INF created table "DEMO_AIRBYTE_MIGRATION"."CUSTOMERS"
INF inserted 20000 rows into "DEMO_AIRBYTE_MIGRATION"."CUSTOMERS" in 17 secs [1,160 r/s] [2.0 MB]
...
INF [3 / 3] running stream demo_airbyte_migration.orders
INF getting checkpoint value (ordered_at)
INF created table "DEMO_AIRBYTE_MIGRATION"."ORDERS"
INF inserted 85000 rows into "DEMO_AIRBYTE_MIGRATION"."ORDERS" in 14 secs [5,945 r/s] [5.5 MB]
INF execution succeeded

INF Sling Replication Completed in 50s | PG_SLING_DEV -> SNOWFLAKE | 3 Successes | 0 Failures

108,000 rows across three tables in 50 seconds, from a laptop, with nothing running underneath it.

Then 2,500 new orders landed on the source and the same command ran again:

$ sling run -r replication.yaml

INF getting checkpoint value (updated_at)
INF inserted 0 rows into "DEMO_AIRBYTE_MIGRATION"."CUSTOMERS" in 10 secs [0 r/s]
INF getting checkpoint value (updated_at)
INF inserted 0 rows into "DEMO_AIRBYTE_MIGRATION"."PRODUCTS" in 9 secs [0 r/s]
INF getting checkpoint value (ordered_at)
INF inserted 2500 rows into "DEMO_AIRBYTE_MIGRATION"."ORDERS" in 13 secs [192 r/s] [152 kB]

INF Sling Replication Completed in 37s | PG_SLING_DEV -> SNOWFLAKE | 3 Successes | 0 Failures

Two quiet streams moved nothing and said so. That getting checkpoint value line is worth understanding, because it marks the biggest structural difference from Airbyte’s state model.

Where the state lives

Airbyte keeps sync state in its own platform database, which is why the deploy includes one. Sling asks the target table.

The getting checkpoint value (ordered_at) step runs MAX(ordered_at) against the destination table and uses the result as the lower bound for the next read. You can see it in getIncrementalValueViaDB in core/sling/task_func.go, which calls GetMaxValue on the target connection.

A few consequences follow from that, and they are worth knowing before you cut over:

  • There is no state database to back up, migrate, or corrupt.
  • To force a re-sync of a window, delete those rows from the target. The next run refills them.
  • To inspect the checkpoint, query your own warehouse: select max(ordered_at) from ....
  • Two Sling processes pointed at the same target table will read the same checkpoint. Coordinate scheduling the same way you would for any job that writes to a shared table.

File targets work differently. Those use a SLING_STATE location, because an object store has no MAX() to ask.

Your airbyte columns will not be there

This is the part that breaks downstream models, so handle it before the cutover, not after.

Under Airbyte’s classic typing-and-deduping model, raw tables land in an airbyte_internal schema with _airbyte_data, _airbyte_raw_id, _airbyte_extracted_at, and _airbyte_loaded_at. The typed final tables in your own schema carry metadata columns too: _airbyte_raw_id, _airbyte_extracted_at, _airbyte_meta, and _airbyte_generation_id. Tables predating Destinations V2 have _airbyte_ab_id instead of _airbyte_raw_id. Airbyte’s Direct Loading option changes the raw-table half of this by writing typed data straight to final tables, but the metadata columns remain.

Sling writes your source columns. That is all. Reading the target back after the run above:

select column_name, data_type
from information_schema.columns
where table_schema = 'DEMO_AIRBYTE_MIGRATION'
  and table_name = 'ORDERS';
+-------------+---------------+
| COLUMN_NAME | DATA_TYPE     |
+-------------+---------------+
| ORDER_ID    | NUMBER        |
| CUSTOMER_ID | NUMBER        |
| PRODUCT_ID  | NUMBER        |
| QUANTITY    | NUMBER        |
| AMOUNT      | NUMBER        |
| STATUS      | TEXT          |
| PROMO_CODE  | TEXT          |
| ORDERED_AT  | TIMESTAMP_NTZ |
+-------------+---------------+

Eight source columns, eight target columns. Before you migrate, grep your dbt project for _airbyte_. Two things usually turn up. Freshness checks built on _airbyte_extracted_at need a real source timestamp instead, and you almost always have one. That swap is an improvement anyway, since a source timestamp measures when the event happened rather than when the pipeline noticed it. Deduplication models built on row_number() over (partition by id order by _airbyte_extracted_at desc) can often be deleted outright. If Sling is running a deduped incremental with a primary_key, the target is already unique on that key.

If you genuinely need an ingestion timestamp, add it in the source query rather than hoping the tool supplies one:

streams:
  demo_airbyte_migration.orders:
    primary_key: [order_id]
    update_key: ordered_at
    sql: |
      select *, current_timestamp as ingested_at
      from demo_airbyte_migration.orders

Verifying the cutover

Run both tools into separate schemas for a few days and compare. The checks that matter:

-- total rows
select count(*) from demo_airbyte_migration.orders;
-- 87500

-- the window covered
select min(ordered_at), max(ordered_at) from demo_airbyte_migration.orders;
-- 2026-02-01 00:00:00 | 2026-07-22 02:40:00

-- no duplicates on the primary key
select count(distinct order_id) from demo_airbyte_migration.orders;
-- 87500

85,000 initial rows plus 2,500 incremental equals 87,500, and the distinct count matches the total. Run that third query every time. If a primary_key went missing somewhere in the translation, a deduped incremental quietly becomes an append, and the distinct count is where it shows up.

Compare against your Airbyte-written table with the metadata columns excluded, since Sling will never produce them:

select count(*) from airbyte_schema.orders
minus
select count(*) from demo_airbyte_migration.orders;

What you give up

A migration guide that only lists wins is not worth much, so here is the other side.

The connector catalog is the big one. Airbyte’s is far larger than ours, and for long-tail SaaS APIs that gap is real. Sling covers databases, file systems, and REST APIs you define yourself in api-spec YAML. Writing your own spec takes an afternoon rather than a multi-week Kotlin SDK project, but it is still your afternoon. If your Airbyte instance is mostly SaaS sources, this migration is not for you.

You also lose the web app. Airbyte’s UI lets a non-engineer configure a pipeline; Sling CLI is a config file in a repo. Whether that reads as a feature or a barrier depends entirely on whether your pipelines belong in code review. Sling Platform adds a UI and scheduling on top of the same engine if you need it.

And Airbyte’s Debezium-based CDC has more production years behind it than ours does. Sling has log-based CDC for Postgres, MySQL, and SQL Server, but if you are running CDC at scale today and it works, that is not the connection to migrate first.

What you get back is the deploy. abctl, the recommended local install path, uses kind to create a Kubernetes cluster inside a Docker container and then installs Airbyte plus an NGINX ingress controller with Helm. The docs note that the install alone can take up to 30 minutes. Sling is one Go binary, 246 MB on disk, running the same replication from cron, a CI runner, or a Lambda.

The licensing differs too. The sling-cli repository is GPL-3.0, an OSI-approved open source license. The root LICENSE file of airbytehq/airbyte is Elastic License 2.0, which is not OSI-approved and restricts offering the software as a managed service. Airbyte’s repository has historically mixed licenses across directories, so check the specific connectors you depend on rather than assuming one license covers the tree.

A migration order that works

  1. Pick one connection, the one whose infrastructure cost annoys you most. Usually a database-to-warehouse sync of a handful of tables.
  2. Export the config via GET /v1/connections/{id} or your Terraform state.
  3. Translate using the mode table above. Watch the array-of-arrays primary_key.
  4. Run into a separate schema. Do not point Sling at the Airbyte-owned tables yet.
  5. Compare for a few days. Totals, window, distinct count on the key.
  6. Repoint downstream models, dropping _airbyte_ references as you go.
  7. Disable the Airbyte connection. Leave it disabled rather than deleted for a week.
  8. Repeat. Airbyte can keep running the SaaS sources indefinitely; nothing forces an all-or-nothing decision.

Most teams get through step 5 on the first connection in well under an hour. The remaining connections go faster, because by then the translation is mechanical.