Migration reference
Diagnostic baseline, SQL compatibility matrix, step-by-step migration sequence, and the failure modes that appear in every migration at scale. What the DuckDB documentation does not cover is the transition cost you will encounter in production.
Most migrations to DuckDB start as a cost reduction exercise and quickly become an architectural question. DuckDB is not a drop-in replacement for either Postgres or Redshift. It is a different class of database with different performance characteristics, different consistency guarantees, and a different operational model. The migration works, and the outcome is almost always cheaper and faster for analytical workloads. But the teams that do it badly spend six weeks debugging query result discrepancies and connection handling edge cases that were entirely predictable.
Before starting a migration, the use case has to match what DuckDB actually is. It is an in-process, single-node, columnar analytical database. It is not a server, it does not handle concurrent writes from multiple processes, and it is not a transactional store.
DuckDB is the correct choice when the workload is read-heavy analytical queries against Parquet, CSV, or JSON files, or against a database that does not require concurrent multi-user writes. ETL pipelines, data transformation layers, reporting engines, and local data analysis all fit well.
When the goal is eliminating the managed database bill and running analytical workloads on client-owned or self-managed infrastructure, DuckDB replaces both the transformation layer and the query engine. The storage format shifts to Parquet on local disk or object storage.
If the application has concurrent write transactions from multiple processes, or if it relies on row-level locking and MVCC under heavy write load, Postgres is the correct database. DuckDB's write concurrency model does not support multiple simultaneous writers to the same file.
Redshift and Snowflake handle 50 concurrent analysts running queries against shared state. DuckDB, embedded in a single process, does not. If the requirement is a multi-tenant query service with user access controls and concurrent query slots, DuckDB is not the correct replacement.
A migration without a baseline has no success criteria. Run these assessments against the source system before writing a single line of migration code.
pg_stat_statements) or Redshift (SVL_QLOG) for the last 30 days. Count unique query patterns, not individual executions.pg_advisory_lock, LISTEN/NOTIFY, row-level security policies, and partitioning via inheritance rather than declarative partitioning.COPY from S3 with IAM roles, Redshift Spectrum, materialised views with incremental refresh.SEQUENCE objects and DEFAULT nextval() but the syntax differs from Postgres.->> operator)| Pattern | Postgres | DuckDB |
|---|---|---|
| JSON field access | col->>'key' | json_extract_string(col, '$.key') |
| Array literal | ARRAY[1, 2, 3] | [1, 2, 3] |
| Array element access | arr[1] (1-based) | arr[1] (1-based, same) |
| String format | format('%s', val) | printf('%s', val) or format('%s', val) |
| Epoch extraction | EXTRACT(EPOCH FROM ts) | EPOCH(ts) |
| Type cast | val::INT | val::INT (same) or CAST(val AS INT) |
| Regex match | col ~ 'pattern' | regexp_matches(col, 'pattern') |
| NULL comparison | IS DISTINCT FROM | IS DISTINCT FROM (same) |
| Generate series | generate_series(1, 10) | range(1, 11) or recursive CTE |
| String split to rows | string_to_table(str, ',') | string_split(str, ',') returns array, then UNNEST |
| Redshift feature | DuckDB equivalent |
|---|---|
| DISTKEY / DISTSTYLE | Not applicable. DuckDB is single-node. |
| SORTKEY / COMPOUND SORTKEY | Not applicable. Use Parquet partitioning and file ordering instead. |
| COPY FROM S3 with IAM | read_parquet('s3://...') with DuckDB httpfs extension. |
| Redshift Spectrum | Native in DuckDB. read_parquet() queries S3 directly. |
| WLM query queues | Not applicable. Use PRAGMA threads to control concurrency. |
| LISTAGG(col, delim) | STRING_AGG(col, delim) |
| DATEDIFF(unit, a, b) | DATE_DIFF('day', a, b) |
| NVL(a, b) | COALESCE(a, b) |
| GETDATE() | CURRENT_DATE or NOW() |
| APPROXIMATE COUNT DISTINCT | approx_count_distinct(col) |
pip install duckdb. For interactive use, the CLI binary is available at duckdb.org.INSTALL httpfs; LOAD httpfs; for S3 access, INSTALL postgres; LOAD postgres; for direct Postgres attachment.PRAGMA memory_limit='8GB'; and PRAGMA threads=4;. DuckDB will use all available CPU and memory by default.pg_dump --schema-only. Review each table definition against the compatibility notes above before running in DuckDB.SERIAL and BIGSERIAL with INTEGER DEFAULT nextval('seq_name') using a DuckDB SEQUENCE, or with UBIGINT DEFAULT nextval('...') for large tables.-- Option 1: Direct Postgres attachment (for databases on the same host or accessible network)
INSTALL postgres; LOAD postgres;
ATTACH 'host=localhost dbname=source_db user=postgres' AS src (TYPE POSTGRES);
-- Migrate a single table to Parquet
COPY (SELECT * FROM src.my_table) TO 'my_table.parquet' (FORMAT PARQUET);
-- Option 2: Export to CSV from Postgres, import to DuckDB
-- On Postgres: \COPY table TO 'table.csv' WITH CSV HEADER
-- In DuckDB:
CREATE TABLE my_table AS
SELECT * FROM read_csv_auto('table.csv');
QUALIFY clause to replace CTEs used solely for window function filtering: SELECT id, ROW_NUMBER() OVER (ORDER BY val DESC) AS rn FROM t QUALIFY rn = 1;LISTAGG with STRING_AGG. Replace DATEDIFF with DATE_DIFF. Replace NVL with COALESCE. These are mechanical substitutions.These are the issues that appear repeatedly across migrations. They are predictable, which means they are preventable.
DuckDB does not support multiple writers to the same file simultaneously. Pipelines that previously wrote to Postgres from multiple parallel processes will fail when the target is DuckDB. The fix is either a single-writer architecture (one process coordinates all writes) or a write-then-merge pattern using separate Parquet files that are merged periodically.
Postgres uses BIGINT for COUNT results. DuckDB uses BIGINT as well, but SUM on an INTEGER column returns BIGINT in Postgres and HUGEINT in DuckDB for very large aggregations. Downstream code that casts the result to a specific integer type will fail silently or raise a type error depending on the connector.
Postgres TIMESTAMP WITH TIME ZONE stores UTC and converts on retrieval based on the session timezone. DuckDB TIMESTAMPTZ also stores UTC, but the conversion behaviour at output can differ depending on the client library. If your data pipeline produces timestamps that appear shifted by a fixed offset after migration, this is the cause. Standardise on UTC at the application layer and store TIMESTAMP (without timezone) if you want deterministic cross-platform behaviour.
The Postgres -> and ->> operators for JSON access do not work in DuckDB. Every query using these operators must be rewritten using DuckDB's json_extract or json_extract_string functions. If JSON access is pervasive across 200+ queries, write a regex-based translation script rather than manually updating each one.
DuckDB processes data in memory by default. A query that works in Redshift against a distributed cluster will run on a single machine in DuckDB. Queries with large GROUP BY operations on high-cardinality columns, or queries with many window functions running in parallel, can exhaust available memory. Set PRAGMA memory_limit and PRAGMA temp_directory before running production workloads so spill-to-disk behaviour is controlled rather than crashing the process.
Postgres lowercases unquoted identifiers. DuckDB also defaults to case-insensitive identifiers, but quoted identifiers preserve case. If the migration process exports schema with quoted column names and the application code references columns without quotes, mismatches appear. Audit all quoted identifiers in the exported DDL before running.
The performance of DuckDB on Parquet files depends on the file structure matching the most common query filter. If you partition by region but all queries filter by date, DuckDB must scan all partitions for every date query. Design the Parquet partition layout around the dominant query pattern from the diagnostic baseline, not the original table structure.
-- Standard session setup for production analytical workloads
PRAGMA threads=8; -- Limit to half available cores
PRAGMA memory_limit='16GB'; -- Leave headroom for OS
PRAGMA temp_directory='/fast/nvme/tmp'; -- Fast disk for spill
-- Extension setup for S3 access
INSTALL httpfs;
LOAD httpfs;
SET s3_region='me-south-1'; -- Set to your S3 region
SET s3_access_key_id='...'; -- Or use IAM role via instance profile
SET s3_secret_access_key='...';
-- Querying Parquet files directly from S3 (replacing Redshift Spectrum)
SELECT
DATE_TRUNC('month', event_date) AS month,
COUNT(*) AS events,
COUNT(DISTINCT user_id) AS unique_users
FROM read_parquet('s3://your-bucket/events/year=*/month=*/*.parquet')
WHERE event_date >= '2026-01-01'
GROUP BY 1 ORDER BY 1;
-- Writing partitioned Parquet output (replacing INSERT INTO Redshift)
COPY (
SELECT * FROM transformed_data
) TO 'output/' (FORMAT PARQUET, PARTITION_BY (year, month));
-- Profile a slow query before optimising
EXPLAIN ANALYZE
SELECT user_id, SUM(amount) FROM transactions
WHERE created_at >= '2026-01-01'
GROUP BY user_id;
A successful migration is a migration that was scoped correctly from the start. DuckDB handles the analytical query layer. It does not replace transactional databases, multi-user query services, or systems that require concurrent writes. Run the diagnostic baseline honestly, translate the schema before the data, test results against the source system, and expect to spend time on JSON operators, timezone handling, and memory configuration. The migration is worth doing. The organisations that run it properly come out with a stack that costs less, runs faster on analytical workloads, and has no infrastructure dependencies they do not control.
Nauman Shahid builds zero-dependency data infrastructure for organisations in the UAE and Gulf region. Companion guide: the Zero-Dependency Data Architecture Blueprint at data.nauman.cc/zero-dependency-architecture/. Diagnostic engagements: www.mindflex.tech. Vendor lock-in audit: audit.nauman.cc.
These documents come from live diagnostic work. If your data infrastructure, vendor exposure, or compliance posture needs attention:
Discuss a diagnostic engagement →