Migration reference

Migrating Postgres and Redshift to DuckDB

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.

AuthorNauman Shahid
CoversPostgres 14+, Redshift, DuckDB 1.x
TypeMigration reference

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.

When DuckDB Is and Is Not the Correct Answer

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.

Correct use: analytical workloads on files

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.

Correct use: zero-dependency architecture

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.

Incorrect use: transactional OLTP

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.

Incorrect use: shared multi-user query service

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.

Diagnostic Baseline: Before You Migrate

A migration without a baseline has no success criteria. Run these assessments against the source system before writing a single line of migration code.

Query inventory

  1. Export query logs from Postgres (pg_stat_statements) or Redshift (SVL_QLOG) for the last 30 days. Count unique query patterns, not individual executions.
  2. Classify by read/write ratio. Queries that are 95%+ reads are migration candidates. Mixed workloads need a decision about whether the write path stays in Postgres.
  3. Identify queries that use Postgres-specific functions with no DuckDB equivalent: pg_advisory_lock, LISTEN/NOTIFY, row-level security policies, and partitioning via inheritance rather than declarative partitioning.
  4. Flag queries that depend on Redshift-specific features: distribution keys, sort keys, COPY from S3 with IAM roles, Redshift Spectrum, materialised views with incremental refresh.

Schema complexity assessment

  1. Count tables with triggers. DuckDB does not support triggers. Any trigger-dependent logic must be moved to the application layer or the ETL pipeline.
  2. Count stored procedures and functions. DuckDB supports macros and scalar functions but not PL/pgSQL procedures. Complex stored procedures must be rewritten as dbt models, Python scripts, or SQL files.
  3. Count foreign key constraints and check constraints. DuckDB supports these in schema definitions but does not enforce foreign keys at write time. Referential integrity must be validated in the pipeline.
  4. Identify SERIAL or IDENTITY columns used as auto-increment primary keys. DuckDB supports SEQUENCE objects and DEFAULT nextval() but the syntax differs from Postgres.

Data volume and format

SQL Compatibility Matrix

What works without changes

Postgres-to-DuckDB: syntax changes required

PatternPostgresDuckDB
JSON field accesscol->>'key'json_extract_string(col, '$.key')
Array literalARRAY[1, 2, 3][1, 2, 3]
Array element accessarr[1] (1-based)arr[1] (1-based, same)
String formatformat('%s', val)printf('%s', val) or format('%s', val)
Epoch extractionEXTRACT(EPOCH FROM ts)EPOCH(ts)
Type castval::INTval::INT (same) or CAST(val AS INT)
Regex matchcol ~ 'pattern'regexp_matches(col, 'pattern')
NULL comparisonIS DISTINCT FROMIS DISTINCT FROM (same)
Generate seriesgenerate_series(1, 10)range(1, 11) or recursive CTE
String split to rowsstring_to_table(str, ',')string_split(str, ',') returns array, then UNNEST

Redshift-to-DuckDB: what does not exist

Redshift featureDuckDB equivalent
DISTKEY / DISTSTYLENot applicable. DuckDB is single-node.
SORTKEY / COMPOUND SORTKEYNot applicable. Use Parquet partitioning and file ordering instead.
COPY FROM S3 with IAMread_parquet('s3://...') with DuckDB httpfs extension.
Redshift SpectrumNative in DuckDB. read_parquet() queries S3 directly.
WLM query queuesNot 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 DISTINCTapprox_count_distinct(col)

Step-by-Step Migration Sequence

Phase 1: Environment and tooling

  1. Install DuckDB. For pipeline use, the Python package is the standard entry point: pip install duckdb. For interactive use, the CLI binary is available at duckdb.org.
  2. Install extensions required for your migration: INSTALL httpfs; LOAD httpfs; for S3 access, INSTALL postgres; LOAD postgres; for direct Postgres attachment.
  3. Set resource limits before running large queries: PRAGMA memory_limit='8GB'; and PRAGMA threads=4;. DuckDB will use all available CPU and memory by default.

Phase 2: Schema migration

  1. Export DDL from Postgres using pg_dump --schema-only. Review each table definition against the compatibility notes above before running in DuckDB.
  2. Remove or comment out: triggers, stored procedures, row-level security policies, table partitioning via inheritance (replace with Parquet partition layout), and extension-specific types (PostGIS, hstore).
  3. Replace SERIAL and BIGSERIAL with INTEGER DEFAULT nextval('seq_name') using a DuckDB SEQUENCE, or with UBIGINT DEFAULT nextval('...') for large tables.
  4. Test CREATE TABLE statements in DuckDB before migrating data. Catch type compatibility issues at the schema layer, not after a 48-hour data export.

Phase 3: Data migration

-- 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');
  1. Migrate large tables in date-partitioned batches. A single 500M row table exported as one Parquet file is valid but creates a suboptimal read pattern for time-filtered queries. Partition by year or year/month during migration.
  2. After migration, run row count and SUM checks on every table against the source. Row counts alone are not sufficient: duplicate detection requires at minimum a COUNT DISTINCT on the primary key.
  3. Run a sample of business-critical queries against both systems on the same data and compare results. Specific areas to check: NULL handling in aggregations (DuckDB and Postgres differ in edge cases), integer division behaviour, and timestamp timezone handling.

Phase 4: Query migration

  1. Translate each query category from the diagnostic inventory. Start with simple SELECT queries, then window function queries, then complex CTEs. Stored procedures last.
  2. Use DuckDB's 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;
  3. Replace Redshift LISTAGG with STRING_AGG. Replace DATEDIFF with DATE_DIFF. Replace NVL with COALESCE. These are mechanical substitutions.
  4. Re-test query results against the source system after translation. Pay particular attention to floating-point aggregations, date arithmetic, and queries that previously relied on Redshift sort key ordering.

Failure Modes

These are the issues that appear repeatedly across migrations. They are predictable, which means they are preventable.

Concurrent write failures

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.

Integer overflow on COUNT and SUM

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.

Timezone behaviour differences

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.

JSON operator incompatibility

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.

Memory exhaustion on large aggregations

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.

Column name case sensitivity

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.

Parquet partitioning not matching query filters

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.

Production Setup Reference

-- 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;

Summary

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 →