DuckDB Delta API
November 7, 2025 · View on GitHub
This repository provides a small FastAPI service that exposes DuckDB-powered queries against Delta tables (via the Delta and UC Catalog extensions).
This README documents the current architecture, data flow, developer workflows, and important implementation conventions so contributors and automated agents can get productive quickly.
High-level architecture
Architecture (component flow)
flowchart LR
Client["Client
(curl / SDK / UI)"]
FW["FastAPI app"]
MW["SessionMiddleware"]
API["API Router"]
DBS["DuckDBSession"]
INITSQL["duckdb_init.sql"]
QS["QueryService"]
CACHE["QueryCacheService"]
TELE["Telemetry / Tracing"]
QueryFiles["query_{sha256}.duckdb\ncache_metadata.json"]
Client -->|HTTP| FW
FW --> MW
MW --> API
API -->|depends| DBS
API -->|calls| QS
QS -->|uses connection| DBS
DBS -->|executes init SQL| INITSQL
QS -->|reads/writes| CACHE
CACHE --> QueryFiles
CACHE --> Redis
Redis -->|table sets| CACHE
FW --> TELE
QS --> TELE
API --> TELE
Key components
- FastAPI application (entry:
src/app/main.py) with a root health endpoint and a single versioned API router mounted at/api/v1(src/app/api/v1/routes.py). - Per-request DuckDB session:
SessionMiddleware(src/app/middleware/session_middleware.py) creates aDuckDBSession(db_path=":memory:")and attaches it torequest.state.duckdb_session. This ensures isolation between requests. - Query execution:
QueryService(src/app/services/query_service.py) accepts the DuckDB connection, runs blocking DuckDB calls insideasyncio.to_thread, converts results to Polars DataFrames (result.pl()), and returns them to the API layer. - Caching layer:
QueryCacheService(src/app/services/query_cache_service.py) optionally persists query results to per-query DuckDB files under./query_cacheand maintains acache_metadata.jsonfile. - Initialization/Extensions:
DuckDBSessionreads initialization SQL from a configurable source and substitutes environment placeholders (e.g.${UC_CATALOG_TOKEN}) before executing statements. By default, it uses the bundledsrc/app/db/duckdb_init.sqlfile, but you can provide a custom initialization file via theduckdb_init_sql_fileenvironment variable. When SQL execution fails, a small fallback sequence installs/loads theuc_cataloganddeltaextensions. See Database Initialization Configuration for detailed setup instructions. - Telemetry: Tracing and lightweight logging helpers live in
src/app/telemetry/tracing.py. The project uses OpenTelemetry with an OTLP exporter when available, otherwise it falls back to the console exporter. Helper mixins and decorators (e.g.TracingMixin,@trace_async_function) are used across services.
Request / data flow (detailed)
Data flow diagram
sequenceDiagram
participant C as Client
participant A as FastAPI
participant M as SessionMiddleware
participant R as APIRouter (/api/v1)
participant QH as QueryHandler
participant QS as QueryService
participant DB as DuckDBConnection
participant QC as QueryCacheService
participant P as Polars
C->>A: POST /api/v1/query (query param or JSON body)
A->>M: middleware.dispatch(request)
M->>M: create DuckDBSession(db_path=":memory:")
M-->>A: request.state.duckdb_session set
A->>R: route handler invoked
R->>QH: validate_input(query|payload)
QH-->>R: validated_query
R->>QS: process_query(validated_query, db_connection)
QS->>QC: get_cached_result(query)
alt cache hit
QC-->>QS: Polars DataFrame (from query_{hash}.duckdb or Redis)
QS->>P: return DataFrame (no DB execution)
else cache miss
QS->>DB: asyncio.to_thread(execute query)
DB-->>QS: DuckDB result -> .pl() -> Polars DataFrame
QS->>QC: store_result(query, DataFrame) [if cacheable]
end
QS-->>R: data + metadata
R-->>C: JSON response { data, metadata }
A->>M: middleware cleanup -> close_connection
Note over QS,QC: Tracing spans created across steps (TracingMixin, decorators)
Step-by-step request flow
- Incoming HTTP request reaches FastAPI (
src/app/main.py). SessionMiddlewareruns for each request:- Creates a new
DuckDBSession(db_path=":memory:"). - Stores it in
request.state.duckdb_session.
- Creates a new
- The API router (
/api/v1/query) accepts queries either via?query=...or JSON body{ "query": "..." }. - The route handler uses
get_db_connectionto obtain a context manager fromDuckDBSession.get_connection()and passes the connection toQueryService. QueryService.execute_query:- Optionally checks cache via
QueryCacheService.get_cached_result(query). - If cache hit: loads data from that per-query DuckDB file, converts to Polars and returns.
- On cache miss: executes the DuckDB query inside
asyncio.to_threadto avoid blocking the event loop, converts results to Polars DataFrame, and — if eligible — stores the result usingQueryCacheService.store_result(query, df). - Caching decision uses
_should_cache_query()— only SELECT-like deterministic queries are cached.
- Optionally checks cache via
- The API endpoint returns JSON:
{ "data": [...], "metadata": { ... } }where metadata includes timing, row/column counts and whether the result came from cache. - After the response is sent, middleware cleans up the DuckDB session (connection close) ensuring no per-request state is reused.
Important implementation conventions
- Per-request isolation: The default
db_pathpassed intoDuckDBSessionis:memory:. To create persistent/shared DBs, change thedb_pathinsession_middleware.py. - Init SQL substitution:
DuckDBSession._substitute_env_variablesreplaces placeholders induckdb_init.sqlwith values fromsrc/app/config/settings.py(loaded from.env). Keep secrets out of the repository. - Cache file format & naming: cache files are DuckDB database files named
query_{sha256(normalized_query)}.duckdbin./query_cacheand metadata lives incache_metadata.json. - Long-running or blocking operations must be run off the event loop: see
QueryServiceusage ofasyncio.to_thread. - Tracing: use
TracingMixin.log_and_trace(...)and the provided decoratorstrace_async_function/trace_functionto add spans.init_tracing()insrc/app/telemetry/tracing.pyauto-selects OTLP or Console exporter.
Key files (quick map)
src/app/main.py— app bootstrap, middleware, tracer init, top-level routes.src/app/middleware/session_middleware.py— per-requestDuckDBSessioncreation and cleanup.src/app/db/duckdb_session.py— DuckDB connection lifecycle andduckdb_init.sqlhandling.src/app/db/duckdb_init.sql— SQL to install/load extensions, create secrets and attach UC catalog (uses env placeholders).src/app/api/v1/routes.py— API endpoints and request flow.src/app/services/query_service.py— query execution, conversion, caching decisions.src/app/services/query_cache_service.py— cache persistence, metadata, and cleanup.src/app/config/settings.py— pydantic-based settings (.env support).src/app/telemetry/tracing.py— tracing utilities and decorators used across the codebase.
How to run
Option 1: Docker Compose (Recommended)
The easiest way to run deltaflock with all dependencies is using Docker Compose:
- Copy the environment template and configure your values:
cp .env.example .env
# Edit .env with your Unity Catalog credentials and other settings
- Build and start all services:
docker compose up -d
This will start:
- deltaflock API at
http://localhost:9000 - Redis for caching at
localhost:6379 - Jaeger UI for tracing at
http://localhost:16686
- Test the API:
curl http://localhost:9000/health
curl "http://localhost:9000/api/v1/query?query=SELECT 1 as test"
- View logs:
docker compose logs -f deltaflock
- Stop services:
docker compose down
Option 2: Local Development
For development you can run deltaflock locally while using Docker for dependencies:
- Start Redis and Jaeger:
docker compose up redis jaeger -d
- Run deltaflock locally:
az login # if using Azure authentication in duckdb_init.sql
uv sync
uv build
uv run deltaflock
This starts the FastAPI server at http://localhost:9000 using the deltaflock console script entry point defined in pyproject.toml. The server displays colorful ASCII art on startup and includes comprehensive OpenTelemetry tracing.

Redis will be available at redis://localhost:6379/0 and Jaeger UI at http://localhost:16686.
If you enable the Redis cache backend (see configuration below) the application will use Redis to store JSON-serialized query results and reuse them across requests.
Configuration: copy .env.example → .env and set values used by src/app/config/settings.py.
Required environment variables:
UC_CATALOG_TOKEN— Unity Catalog authentication token (referenced by initialization SQL)UC_CATALOG_ENDPOINT— Unity Catalog endpoint URL
Optional environment variables:
- Azure credentials (
AZURE_TENANT_ID,AZURE_CLIENT_ID,AZURE_CLIENT_SECRET) for Azure authentication ADMIN_API_KEY— required for/api/v1/cache/clear_by_tableendpoint accessCACHE_BACKEND— set toredisto enable Redis caching (requires Redis dependency)OTEL_EXPORTER_OTLP_ENDPOINT— set tohttp://localhost:4317to enable Jaeger tracingDUCKDB_INIT_SQL_FILE— path to custom database initialization SQL file (see Database Initialization Configuration)
To enable OpenTelemetry tracing with Jaeger, set the OTEL_EXPORTER_OTLP_ENDPOINT variable in your .env file to http://localhost:4317.
Note: tracing falls back to console exporter if the OTLP endpoint is unreachable.
Cache & debugging tips
- Cache directory:
./query_cache(files:query_{hash}.duckdb, metadata:cache_metadata.json). - To inspect cache metadata: open
query_cache/cache_metadata.json. - If cache files appear corrupted,
QueryCacheServicewill attempt to remove them when loading fails. - If extension installation/loading fails, inspect your initialization SQL file (default:
src/app/db/duckdb_init.sqlor custom file set viaDUCKDB_INIT_SQL_FILE) and supply required environment variables. The session initialization has a fallback sequence that runsINSTALL/LOADcommands programmatically. For custom configuration, see Database Initialization Configuration. - ASCII Art: The server displays colorful ASCII art on startup using
scripts/render_ascii.pyto rendersrc/app/static/ascii_art.html.
Cache configuration options
This project supports two cache backends. Configure via environment variables (or .env) which are read by src/app/config/settings.py.
CACHE_BACKEND(default:file) — choosefileto store per-query DuckDB files under./query_cache, orredisto store JSON results in Redis. Note: Redis is a required dependency when usingredisbackend.REDIS_URL(default:redis://localhost:6379/0) — connection URL used whenCACHE_BACKEND=redis.CACHE_EXPIRY_HOURS— controls TTL for file-based cache expiration and defaults for Redis TTL when not explicitly set.REDIS_CACHE_TTL_SECONDS— optional override for Redis TTL (in seconds). If not set it defaults toCACHE_EXPIRY_HOURS * 3600.
Notes:
- In
redismode, cached results are stored as a JSON array of row objects under keys namedquery:{sha256(normalized_query)}and are set with an expiration TTL. The cache also stores metadata for each cached query underquery_meta:{hash}and maintains per-table setstable:{table_name}that list query hashes referencing that table. This enables table-scoped invalidation using/api/v1/cache/clear_by_tablewhen using the Redis backend. - Expired entries in Redis are handled by Redis TTL semantics;
clear_expired_cacheis effectively a no-op for the Redis backend.clear_all_cachewill remove keys matching the prefixesquery:*,query_meta:*, andtable:*.
API Endpoints
The service provides these cache management endpoints:
GET /api/v1/cache/stats— retrieve cache statistics and metadataPOST /api/v1/cache/clear?clear_type=all|expired— clear cache entries globallyPOST /api/v1/cache/clear_by_table— clear cache entries that reference specific table names (requires admin API key)GET /api/v1/cache/health— check cache backend health status
Switch backends by updating your .env or environment variables and restarting the service.
Contributing & changes
WIP
License
MIT