Migrating from DuckDB to DuckLake is very simple to do with the DuckDB ducklake
extension. However, if you are currently using some DuckDB features that are unsupported in DuckLake, this guide will definitely help you.
First Scenario: Everything is Supported
If you are not using any of the unsupported features, migrating from DuckDB to DuckLake will be as simple as running the following commands:
ATTACH 'ducklake:my_ducklake.ducklake' AS my_ducklake;
ATTACH 'duckdb.db' AS my_duckdb;
COPY FROM DATABASE my_duckdb TO my_ducklake;
Note that it doesn't matter what catalog you are using as a metadata backend for DuckLake.
Second Scenario: Not Everything is Supported
If you have been using DuckDB for a while, there is a chance you are using some very specific types, macros, default values that are not literals or even things like generated columns. If this is your case, then migrating will have some tradeoffs.
-
Specific types need to be cast to a supported DuckLake type. User defined types that are created as a
STRUCT
can be interpreted as such andENUM
andUNION
will be cast toVARCHAR
andVARINT
will be cast toINT
. -
Macros can be migrated to a DuckDB persisted database. If you are using DuckDB as your catalog for DuckLake, then this will be the destination. If you are using other catalogs like PostgreSQL, SQLite or MySQL, DuckDB macros are not supported and therefore can't be migrated.
-
Default values that are not literals require you to change the logic of your insertion. See the following example:
-- Works in DuckDB, doesn't work in DuckLake CREATE TABLE t1 (id INTEGER, d DATE DEFAULT now()); INSERT INTO t1 VALUES (2); -- Works in DuckLake and simulates the same behavior CREATE TABLE t1 (id INTEGER, d DATE); INSERT INTO t1 VALUES(2, now());
-
Generated columns are the same as defaults that are not literals and therefore they need to be specified when inserting the data into the destination table. This means that the values will always be persisted (no
VIRTUAL
option).
Migration Script
The following Python script can be used to migrate from a DuckDB persisted database to DuckLake bypassing the unsupported features.
Currently, only local migrations are supported by this script. The script will be adapted in the future to account for migrations to remote object storage such as S3 or GCS.
import duckdb
import argparse
import re
import os
from collections import deque
TYPE_MAPPING = {
"VARINT": "::VARCHAR::INT",
"UNION/ENUM": "::VARCHAR",
"BIT": "::VARCHAR",
}
def get_postgres_secret():
return f"""
CREATE SECRET postgres_secret(
TYPE postgres,
HOST '{os.getenv("POSTGRES_HOST", "localhost")}',
PORT {os.getenv("POSTGRES_PORT", "5432")},
DATABASE {os.getenv("POSTGRES_DB", "migration_test")},
USER '{os.getenv("POSTGRES_USER", "user")}',
PASSWORD '{os.getenv("POSTGRES_PASSWORD", "simple")}'
);"""
def _resolve_data_types(
table: str, schema: str, catalog: str, conn: duckdb.DuckDBPyConnection
):
excepts = []
casts = []
for col in conn.execute(
f"SELECT column_name, data_type FROM information_schema.columns WHERE table_name = '{table}' AND table_schema = '{schema}' AND table_catalog = '{catalog}'"
).fetchall():
col_name, col_type = col[0], col[1]
# Handle mapped types
if col_type in TYPE_MAPPING or re.match(r"(ENUM|UNION)\b", col_type):
cast = TYPE_MAPPING.get(col_type) or TYPE_MAPPING["UNION/ENUM"]
casts.append(f"{col_name}{cast} AS {col_name}")
excepts.append(col_name)
# Handle array types
elif re.fullmatch(r"(INTEGER|VARCHAR|FLOAT)\[\d+\]", col_type):
base_type = re.match(r"(INTEGER|VARCHAR|FLOAT)", col_type).group(1)
cast = f"::{base_type}[]"
casts.append(f"{col_name}{cast} AS {col_name}")
excepts.append(col_name)
return excepts, casts
def migrate_tables_and_views(duckdb_catalog: str, con: duckdb.DuckDBPyConnection):
"""
Migrate tables and views from the DuckDB catalog to DuckLake using a queue system.
If migration of a table or view fails, it will be re-added to the back of the queue.
"""
rows = con.execute(
f"SELECT table_catalog, table_schema, table_name, table_type "
f"FROM information_schema.tables WHERE table_catalog = '{duckdb_catalog}'"
).fetchall()
# The idea behind this queue is to retry failed migration of views due to missing dependencies.
# The failed item is re-added to the back of the queue and waits for the rest of the dependencies to be migrated.
# This avoids the need to generate a full dependency graph, which would make this script very complex.
queue = deque(rows)
failed_last_round = set()
while queue:
catalog, schema, table, table_type = queue.popleft()
con.execute(f"CREATE SCHEMA IF NOT EXISTS {schema}")
try:
if table_type == "VIEW":
view_definition = con.execute(
f"SELECT view_definition FROM information_schema.views "
f"WHERE table_name = '{table}' AND table_schema = '{schema}' AND table_catalog = '{catalog}'"
).fetchone()[0]
con.execute(
f"CREATE VIEW IF NOT EXISTS {view_definition.removeprefix('CREATE VIEW ')}"
)
print(f"Migrating Catalog: {catalog}, Schema: {schema}, View: {table}")
else:
excepts, casts = _resolve_data_types(table, schema, catalog, con)
if casts:
select_clause = (
"* EXCLUDE(" + ", ".join(excepts) + "),\n" + ",\n".join(casts)
)
con.execute(
f"CREATE TABLE IF NOT EXISTS {schema}.{table} AS "
f"SELECT {select_clause} FROM {catalog}.{schema}.{table}"
)
else:
con.execute(
f"CREATE TABLE IF NOT EXISTS {schema}.{table} AS "
f"SELECT * FROM {catalog}.{schema}.{table}"
)
print(f"Migrating Catalog: {catalog}, Schema: {schema}, Table: {table}")
except Exception as e:
print(f"WARNING: Requeuing {table_type} {table}")
# Prevent infinite loop if no progress is possible
if (catalog, schema, table, table_type) in failed_last_round:
print(
f"Skipping {table_type} {table} permanently due to repeated failure. {e}"
)
continue
else:
queue.append((catalog, schema, table, table_type))
failed_last_round.add((catalog, schema, table, table_type))
else:
# Success — ensure we clear from failure set
failed_last_round.discard((catalog, schema, table, table_type))
def migrate_macros(con: duckdb.DuckDBPyConnection, duckdb_catalog: str):
"""
Migrate macros from the DuckDB catalog to DuckLake metadata database.
"""
for row in con.execute(
f"SELECT function_name, parameters, macro_definition FROM duckdb_functions() "
f"WHERE database_name='{duckdb_catalog}'"
).fetchall():
name, parameters, definition = row[0], row[1], row[2]
print(f"Migrating Macro: {name}")
con.execute(
f"CREATE OR REPLACE MACRO {name}({','.join(parameters)}) AS {definition}"
)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Migrate DuckDB catalog to DuckLake.")
parser.add_argument("--duckdb-catalog", required=True, help="DuckDB catalog name")
parser.add_argument("--duckdb-file", required=True, help="Path to DuckDB file")
parser.add_argument(
"--ducklake-catalog", required=True, help="DuckLake catalog name"
)
parser.add_argument(
"--catalog-type",
choices=["duckdb", "postgresql", "sqlite"],
required=True,
help="Choose one of: duckdb, postgresql, sqlite",
)
parser.add_argument("--ducklake-file", required=False, help="Path to DuckLake file")
parser.add_argument(
"--ducklake-data-path", required=True, help="Data path for DuckLake"
)
args = parser.parse_args()
con = duckdb.connect(database=args.duckdb_file)
if args.catalog_type == "postgresql":
con.execute(get_postgres_secret())
secret = (
"CREATE SECRET ducklake_secret (TYPE DUCKLAKE"
+ (
f"\n,METADATA_PATH '{args.ducklake_file if args.catalog_type == 'duckdb' else f'sqlite:{args.ducklake_file}'}'"
if args.catalog_type in ("duckdb", "sqlite")
else "\n,METADATA_PATH ''"
)
+ f"\n,DATA_PATH '{args.ducklake_data_path}'"
+ (
"\n,METADATA_PARAMETERS MAP {'TYPE': 'postgres', 'SECRET': 'postgres_secret'});"
if args.catalog_type == "postgresql"
else ");"
)
)
con.execute(secret)
con.execute(
f"ATTACH '{args.duckdb_file}' AS {args.duckdb_catalog};"
f"ATTACH 'ducklake:ducklake_secret' AS {args.ducklake_catalog}; USE {args.ducklake_catalog};"
)
migrate_tables_and_views(
duckdb_catalog=args.duckdb_catalog,
con=con,
)
if args.catalog_type == "duckdb":
# DETACH ducklake to be able to attach to the metadata database in migrate_macros
con.execute(f"USE {args.duckdb_catalog}; DETACH {args.ducklake_catalog};")
con.execute(
f"ATTACH '{args.ducklake_file}' AS ducklake_metadata; USE ducklake_metadata;"
)
migrate_macros(
con=con,
duckdb_catalog=args.duckdb_catalog,
)
con.close()
The script can be run in any Python environment with DuckDB installed. The usage is the following:
usage: migration.py [-h]
--duckdb-catalog DUCKDB_CATALOG
--duckdb-file DUCKDB_FILE
--ducklake-catalog DUCKLAKE_CATALOG
--catalog-type{duckdb,postgresql,sqlite}
[--ducklake-file DUCKLAKE_FILE]
--ducklake-data-path DUCKLAKE_DATA_PATH
If you are migrating to PostgreSQL, make sure that you provide the following environment variables for the PostgreSQL secret connection:
POSTGRES_HOST
POSTGRES_PORT
POSTGRES_DB
POSTGRES_USER
POSTGRES_PASSWORD