Enrich rows from Postgres or MySQL safely
Use this cookbook when production rows live in an OLTP database. The safe pattern is to attach the source read-only, materialize a bounded local working set, run AI enrichment locally, and write results to an explicit staging target.
Prerequisites
- Use read-only database credentials for the source attachment.
- Configure a completion provider with a
TYPE duckdb_aisecret. - Configure object-storage credentials if the enriched output will be exported to S3-compatible storage.
- Decide whether enriched rows should be exported to files or inserted into a separate staging table in the source database.
INSTALL ai FROM community;
LOAD ai;
INSTALL postgres;
LOAD postgres;
INSTALL mysql;
LOAD mysql;
INSTALL httpfs;
LOAD httpfs;
INSTALL aws;
LOAD aws;
CREATE OR REPLACE SECRET openai_ai (
TYPE duckdb_ai,
AI_PROVIDER 'openai',
MODEL 'gpt-4o-mini'
);
CREATE OR REPLACE SECRET s3_prod (
TYPE s3,
PROVIDER credential_chain,
REGION 'us-east-1'
);
SET VARIABLE run_id = 'source-enrichment-2026-07-07T100000Z';
Attach source databases read-only
Store source credentials in DuckDB secrets. Avoid putting passwords in connection strings, because failed connection errors can print the full string.
CREATE OR REPLACE SECRET app_postgres (
TYPE postgres,
HOST 'postgres.internal.example',
PORT 5432,
DATABASE 'app',
USER 'readonly_ai_worker',
PASSWORD '<password>'
);
ATTACH '' AS app_pg (
TYPE postgres,
SECRET app_postgres,
READ_ONLY,
SCHEMA 'public'
);
For MySQL, use the same pattern:
CREATE OR REPLACE SECRET app_mysql (
TYPE mysql,
HOST 'mysql.internal.example',
PORT 3306,
DATABASE 'app',
USER 'readonly_ai_worker',
PASSWORD '<password>'
);
ATTACH '' AS app_mysql (
TYPE mysql,
SECRET app_mysql,
READ_ONLY
);
Materialize a local working set
Filter the source table before any provider calls. This keeps scans predictable and avoids re-reading the OLTP database during retry or export steps.
CREATE OR REPLACE TEMP TABLE source_ticket_batch AS
SELECT
ticket_id,
customer_id,
priority,
status,
subject,
body,
updated_at,
getvariable('run_id') AS run_id
FROM app_pg.public.support_tickets
WHERE status = 'open'
AND priority IN ('high', 'urgent')
ORDER BY updated_at
LIMIT 1000;
If the source schema changed while the same DuckDB connection is running, clear the extension schema cache before re-querying:
SELECT pg_clear_cache();
Use mysql_clear_cache() for attached MySQL sources.
Enrich the local batch
Run provider calls only after the batch is local. Use ai_try_complete so bad
rows become rejected rows instead of aborting the whole job.
CREATE OR REPLACE TEMP TABLE source_ticket_attempts AS
SELECT
run_id,
ticket_id,
customer_id,
priority,
ai_try_complete(
'Return JSON with keys summary, next_action, risk_level. '
|| 'risk_level must be low, medium, or high.'
|| chr(10) || 'Subject: ' || subject
|| chr(10) || 'Body: ' || body,
secret := 'openai_ai',
response_format := 'json_object',
retry_count := 2,
retry_backoff_ms := 1000,
max_concurrent_requests := 4,
token_limit_per_minute := 200000
) AS result
FROM source_ticket_batch;
Export or stage the output
For the lowest-risk production path, export success and rejection files and let a separate application-owned process load them into the source system.
COPY (
SELECT
run_id,
ticket_id,
customer_id,
priority,
result.response::JSON AS response_json,
now() AS loaded_at
FROM source_ticket_attempts
WHERE result.error IS NULL
)
TO 's3://support-prod/ai/source_ticket_enriched/run_id=2026-07-07T100000Z/'
(FORMAT parquet, COMPRESSION zstd, OVERWRITE_OR_IGNORE true);
COPY (
SELECT
run_id,
ticket_id,
customer_id,
priority,
result.error AS error,
now() AS loaded_at
FROM source_ticket_attempts
WHERE result.error IS NOT NULL
)
TO 's3://support-prod/ai/source_ticket_rejected/run_id=2026-07-07T100000Z/'
(FORMAT parquet, COMPRESSION zstd, OVERWRITE_OR_IGNORE true);
If you intentionally write back to Postgres or MySQL, use a separate writable attachment and write only to a staging table. Review that staging table before merging into production tables.
CREATE OR REPLACE SECRET app_postgres_writer (
TYPE postgres,
HOST 'postgres.internal.example',
PORT 5432,
DATABASE 'app',
USER 'ai_staging_writer',
PASSWORD '<password>'
);
ATTACH '' AS app_pg_write (
TYPE postgres,
SECRET app_postgres_writer,
SCHEMA 'public'
);
CREATE TABLE IF NOT EXISTS app_pg_write.public.ai_ticket_triage_staging (
run_id VARCHAR,
ticket_id BIGINT,
customer_id BIGINT,
response_json JSON,
loaded_at TIMESTAMP
);
INSERT INTO app_pg_write.public.ai_ticket_triage_staging
SELECT
run_id,
ticket_id,
customer_id,
result.response::JSON AS response_json,
now() AS loaded_at
FROM source_ticket_attempts
WHERE result.error IS NULL;
Do not write provider output directly into customer-facing source columns until you have a review, backfill, and rollback plan.
Capture usage
Persist usage events with the same run id:
COPY (
SELECT getvariable('run_id') AS run_id, now() AS captured_at, *
FROM ai_usage()
)
TO 's3://support-prod/ai/source_ticket_usage/run_id=2026-07-07T100000Z/'
(FORMAT parquet, COMPRESSION zstd, OVERWRITE_OR_IGNORE true);