DuckDB

storage

DuckDB

Definition

DuckDB is an embedded analytical database engine optimized for Online Analytical Processing (OLAP) workloads. It uses columnar storage, vectorized query execution, and automatic parallelism to process analytical queries over large datasets with exceptional performance. As an in-process database, DuckDB requires no separate server, operates directly within the application process, and stores data in a single file or entirely in memory. DuckDB supports standard SQL with extensions for window functions, CTEs, lateral joins, and direct querying of external file formats including Parquet, CSV, JSON, and Arrow IPC. Its design philosophy prioritizes analytical query performance, developer ergonomics, and zero-configuration deployment.

DuckDB occupies a unique position in the database landscape: it provides the analytical query capabilities of column-oriented data warehouses (Snowflake, BigQuery, ClickHouse) in an embedded form factor similar to SQLite. This combination makes it ideal for analytical workloads that need to run alongside an application without the operational overhead of a separate database server. The project originated from research at CWI (Centrum Wiskunde & Informatica) in the Netherlands, where the same research group previously developed MonetDB, one of the pioneering columnar database systems.

OLTP vs OLAP: Fundamental Architecture Differences

The relational database market has historically been divided between OLTP (Online Transaction Processing) systems optimized for high-concurrency point reads and writes, and OLAP systems optimized for complex analytical queries over large datasets. OLTP databases like PostgreSQL store data row-by-row, which is efficient for reading or writing entire records but inefficient for aggregations that touch only a few columns across millions of rows. OLAP databases like DuckDB store data column-by-column, which is highly efficient for analytical aggregations but less suited for single-record operations.

CharacteristicOLTP (PostgreSQL)OLAP (DuckDB)
Storage layoutRow-orientedColumn-oriented
Optimized forPoint reads/writesAggregations/scans
ConcurrencyHigh (many users)Low (few analysts)
Query patternSELECT * WHERE id = XSELECT AVG(col) GROUP BY Y
Transaction supportFull ACIDLimited
Data volumeModerate (GB)Large (TB)
Index strategyB-tree, hash, GINMin-max, zone maps
CompressionRow-levelColumn-level (10-100x better)

DuckDB's key innovation is bringing OLAP-grade analytical performance to an embedded, serverless form factor. Traditional OLAP databases require dedicated infrastructure, ETL pipelines, and operational expertise. DuckDB eliminates all of these requirements while delivering comparable analytical performance through three core technologies:

TechnologyMechanismPerformance Impact
Columnar storageData stored by column, not by row10-100x compression; only needed columns read from disk
Vectorized executionOperations process batches of 1024+ values using SIMD5-20x faster than row-at-a-time processing
Automatic parallelismQueries automatically parallelized across CPU coresNear-linear scaling with core count
Late materializationDefers row reconstruction until final resultReduces memory usage and intermediate data size
Adaptive indexingAutomatically creates min-max indices on columnar segmentsSkips irrelevant data segments during scans
Zero-copy Parquet readingDirectly queries Parquet files without importEliminates ETL latency and storage duplication

Columnar Storage Architecture

In a row-oriented database, a table with columns (id, domain, score, grade) stores data as complete rows:

Row 1: [1, "example.com", 780, "B"]
Row 2: [2, "test.org", 850, "A"]
Row 3: [3, "insecure.net", 350, "F"]

In DuckDB's columnar storage, the same data is organized by column:

id column:     [1, 2, 3]
domain column: ["example.com", "test.org", "insecure.net"]
score column:  [780, 850, 350]
grade column:  ["B", "A", "F"]

This layout provides dramatic advantages for analytical queries. A query like SELECT AVG(score) FROM ratings WHERE grade = 'A' reads only the score and grade columns, skipping the id and domain columns entirely. For wide tables with dozens of columns, this reduces I/O by 90% or more. Additionally, columnar storage enables superior compression because values in the same column tend to have similar types and distributions, allowing techniques like dictionary encoding, run-length encoding, and delta encoding to achieve 10-100x compression ratios.

Vectorized Query Execution

DuckDB processes data in vectors (batches of values) rather than one row at a time. This enables modern CPUs to use SIMD (Single Instruction, Multiple Data) instructions that operate on multiple values simultaneously:

Execution ModelValues per OperationCPU Cache UtilizationExample
Row-at-a-time1Poor (random access)Traditional databases
Vectorized1024-2048Excellent (sequential)DuckDB, Velox
CompiledFull columnExcellentHyper, Umbra

The vectorized execution model processes a pipeline of operators (scan, filter, project, aggregate) on batches of 1024-2048 values. Each operator receives a vector of values, processes the entire vector in a tight loop, and passes the result to the next operator. This eliminates the per-row overhead of virtual function calls and enables the CPU to prefetch sequential memory accesses, dramatically improving cache utilization.

SQL Capabilities

DuckDB supports a rich SQL dialect that goes beyond standard SQL, including features from the latest SQL standards and pragmatic extensions:

-- Window functions for time-series analysis
SELECT
    domain,
    score,
    grade,
    LAG(score) OVER (PARTITION BY domain ORDER BY assessed_at) AS prev_score,
    score - LAG(score) OVER (PARTITION BY domain ORDER BY assessed_at) AS score_delta
FROM security_ratings
ORDER BY domain, assessed_at;

-- Direct Parquet file querying (no import needed)
SELECT domain, AVG(score) as avg_score
FROM read_parquet('easm_results/*.parquet')
GROUP BY domain
HAVING AVG(score) < 500;

-- Common Table Expressions with recursive queries
WITH RECURSIVE domain_hierarchy AS (
    SELECT domain, parent_domain, 1 AS depth
    FROM asset_inventory WHERE parent_domain IS NULL
    UNION ALL
    SELECT a.domain, a.parent_domain, h.depth + 1
    FROM asset_inventory a
    JOIN domain_hierarchy h ON a.parent_domain = h.domain
)
SELECT * FROM domain_hierarchy ORDER BY depth;

-- QUALIFY clause (DuckDB extension -- filter after window functions)
SELECT domain, score, RANK() OVER (ORDER BY score DESC) AS rank
FROM security_ratings
QUALIFY rank <= 10;

-- List/struct types for semi-structured data
SELECT domain, list_agg(vulnerability ORDER BY severity) AS vulns
FROM asset_vulnerabilities
GROUP BY domain;

Implementation in Prismatic Platform

Within the Prismatic Platform, DuckDB is integrated through the storage adapter pattern via prismatic_storage_duckdb for analytical workloads that exceed PostgreSQL's OLTP-optimized performance profile:

defmodule PrismaticStorage.DuckDB.Adapter do
  @moduledoc """
  DuckDB storage adapter for analytical queries.
  Implements the PrismaticStorage.Adapter behaviour for OLAP workloads.
  Provides zero-configuration embedded analytics with columnar storage,
  vectorized execution, and direct Parquet file querying.
  """

  @behaviour PrismaticStorage.Adapter

  @type query_result :: {:ok, [map()]} | {:error, term()}

  @spec query(String.t(), [term()]) :: query_result()
  def query(sql, params \\ []) do
    case Duckdbex.query(get_connection(), sql, params) do
      {:ok, result} ->
        {:ok, result_to_maps(result)}

      {:error, reason} ->
        {:error, {:duckdb_query_failed, reason}}
    end
  end

  @spec aggregate_security_metrics(String.t(), keyword()) :: {:ok, map()} | {:error, term()}
  def aggregate_security_metrics(domain, opts \\ []) do
    time_range = Keyword.get(opts, :time_range, "30 days")

    sql = """
    SELECT
      domain,
      COUNT(*) as assessment_count,
      AVG(score) as avg_score,
      MIN(score) as min_score,
      MAX(score) as max_score,
      STDDEV(score) as score_stddev,
      MODE(grade) as most_common_grade,
      PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY score) as p95_score
    FROM security_ratings
    WHERE domain = $1
      AND assessed_at >= CURRENT_TIMESTAMP - INTERVAL '#{time_range}'
    GROUP BY domain
    """

    query(sql, [domain])
  end

  @spec import_parquet(String.t(), String.t()) :: {:ok, non_neg_integer()} | {:error, term()}
  def import_parquet(table_name, parquet_path) do
    sql = "CREATE TABLE IF NOT EXISTS #{table_name} AS SELECT * FROM read_parquet($1)"
    query(sql, [parquet_path])
  end

  @spec export_parquet(String.t(), String.t()) :: {:ok, String.t()} | {:error, term()}
  def export_parquet(table_name, output_path) do
    sql = "COPY #{table_name} TO $1 (FORMAT PARQUET, COMPRESSION ZSTD)"
    case query(sql, [output_path]) do
      {:ok, _} -> {:ok, output_path}
      error -> error
    end
  end

  defp get_connection do
    case Process.get(:duckdb_conn) do
      nil ->
        {:ok, db} = Duckdbex.open(config_path())
        {:ok, conn} = Duckdbex.connection(db)
        Process.put(:duckdb_conn, conn)
        conn

      conn ->
        conn
    end
  end

  defp config_path do
    Application.get_env(:prismatic_storage_duckdb, :database_path, ":memory:")
  end

  defp result_to_maps({columns, rows}) do
    Enum.map(rows, fn row ->
      columns
      |> Enum.zip(row)
      |> Map.new()
    end)
  end
end

Platform Use Cases

Use CaseQuery PatternWhy DuckDB Over PostgreSQL
Security metrics aggregationAVG, PERCENTILE, STDDEV over thousands of assets10-50x faster for large aggregations
Trend analysisWindow functions over time-series rating historyColumnar storage optimal for time-series scans
OSINT correlationCross-source joins on large intelligence datasetsParallel hash joins on multi-million row datasets
Agent analyticsAggregating execution metrics for 530 agentsIn-memory analytical queries without DB round-trip
Export analyticsParquet/CSV export of analysis resultsNative format support without serialization overhead
Ad-hoc explorationInteractive SQL on collected EASM dataZero-setup analytical environment
Quality metricsHistorical quality score trends across 115 appsColumnar scans across time-series data

Integration with Broadway Pipelines

DuckDB integrates with the platform's Broadway data processing pipelines for batch analytical ingestion:

defmodule PrismaticAnalytics.DuckDBSink do
  @moduledoc """
  Broadway batch processor that sinks analytical data into DuckDB
  for downstream analytical queries. Receives batches from
  Broadway pipelines and bulk-inserts into DuckDB tables.
  """

  @spec process_batch(String.t(), [map()]) :: {:ok, non_neg_integer()} | {:error, term()}
  def process_batch(table_name, records) when is_list(records) do
    columns = records |> List.first() |> Map.keys()
    placeholders = Enum.map_join(1..length(columns), ", ", &"$#{&1}")

    sql = """
    INSERT INTO #{table_name} (#{Enum.join(columns, ", ")})
    VALUES (#{placeholders})
    """

    results =
      Enum.map(records, fn record ->
        values = Enum.map(columns, &Map.get(record, &1))
        PrismaticStorage.DuckDB.Adapter.query(sql, values)
      end)

    success_count = Enum.count(results, &match?({:ok, _}, &1))
    {:ok, success_count}
  end
end

Memory Management and Resource Control

When DuckDB runs embedded within a BEAM application, resource management requires careful configuration to prevent DuckDB from competing with Erlang processes for memory:

defmodule PrismaticStorage.DuckDB.Config do
  @moduledoc """
  Configuration management for embedded DuckDB instances.
  Ensures DuckDB resource usage stays within bounds that
  allow healthy coexistence with the BEAM runtime.
  """

  @spec configure_resource_limits(Duckdbex.connection()) :: :ok | {:error, term()}
  def configure_resource_limits(conn) do
    limits = [
      {"SET memory_limit = '2GB'", []},
      {"SET threads = #{max(System.schedulers_online() - 2, 1)}", []},
      {"SET temp_directory = '/tmp/duckdb_temp'", []},
      {"SET max_temp_directory_size = '5GB'", []}
    ]

    Enum.each(limits, fn {sql, params} ->
      Duckdbex.query(conn, sql, params)
    end)

    :ok
  end
end
ResourceDefaultRecommended for BEAMRationale
Memory limitUnlimited2GBLeave headroom for BEAM processes and ETS
ThreadsAll coresCores - 2Reserve cores for BEAM schedulers
Temp directoryWorking dir/tmp/duckdb_tempAvoid polluting project directory
Max temp sizeUnlimited5GBPrevent disk exhaustion from large sorts

Performance Benchmarks

Typical performance comparisons between DuckDB and PostgreSQL for analytical workloads on the Prismatic Platform's data scale:

Query TypePostgreSQLDuckDBSpeedup
COUNT(*) over 1M rows250ms15ms16.7x
AVG with GROUP BY (100K groups)800ms45ms17.8x
PERCENTILE_CONT (P95)1,200ms80ms15x
Window function (LAG) over 500K rows1,500ms120ms12.5x
Multi-table JOIN + aggregate3,000ms200ms15x
Parquet file scan (1GB)N/A (requires import)500msN/A

Comparison with Alternatives

DatabaseTypeDeploymentBest ForPrismatic Role
DuckDBEmbedded OLAPIn-processAnalytical aggregations, ad-hoc queriesAnalytical workloads
PostgreSQLServer OLTP/OLAPExternal serverTransactional data, ACID writesSource of truth
SQLiteEmbedded OLTPIn-processSimple key-value, local storageNot used (DuckDB preferred)
ClickHouseServer OLAPExternal serverHigh-volume time-series ingestionToo heavy for platform scale
ETSIn-memory KVBEAM nativeFast key-value lookups, cachingReal-time cache
MeilisearchSearch engineExternal serverFull-text search, typo toleranceSearch workloads
KuzuDBGraph databaseEmbeddedRelationship traversal, graph analyticsGraph queries
SnowflakeCloud OLAPSaaSPetabyte-scale analyticsOverkill for platform needs

Best Practices

  1. Use DuckDB for Analytics, PostgreSQL for Transactions: DuckDB excels at read-heavy analytical queries over large datasets. PostgreSQL remains the correct choice for ACID transactional writes, concurrent multi-user access, and primary data storage.

  2. Leverage Direct File Queries: DuckDB can query Parquet, CSV, and JSON files directly without importing. Use this capability for ad-hoc analysis of exported data, log files, and external intelligence feeds.

  3. Configure Memory Limits: DuckDB will use all available memory by default. In an embedded context alongside BEAM processes, configure explicit memory limits to prevent resource contention: SET memory_limit = '2GB'.

  4. Use In-Memory for Ephemeral Analytics: For queries over data that exists in PostgreSQL or ETS, load into DuckDB in-memory tables rather than persisting to DuckDB storage. This avoids data synchronization complexity.

  5. Batch Ingestion for Performance: When loading data into DuckDB, use bulk operations (COPY, INSERT...SELECT) rather than single-row inserts. DuckDB's columnar storage is optimized for batch writes.

  6. Index Strategy: DuckDB automatically creates lightweight min-max indices on column segments. Unlike PostgreSQL, explicit index creation is rarely necessary and can be counterproductive for scan-heavy workloads.

  7. Prefer Parquet for Data Exchange: When exporting analytical results or exchanging data between systems, use Parquet format for its columnar layout, compression, and schema preservation.

  • Adapter Pattern - Unified storage interface including DuckDB as the OLAP adapter
  • ETS - Complementary in-memory storage for caching and fast key-value access
  • KuzuDB - Graph database complementing DuckDB for relationship queries
  • PostgreSQL - Primary relational OLTP database that DuckDB supplements for analytics
  • Meilisearch - Full-text search engine complementing DuckDB's analytical capabilities
  • EASM - Domain generating analytical workloads processed by DuckDB
  • Broadway - Data pipeline feeding analytical data into DuckDB
  • Connection Pooling - Resource management pattern for database connections
  • BEAM - Virtual machine hosting the embedded DuckDB instances

See Also


Connect & Contribute

Created by Tomas Korcak (korczis) | Open Source under GHL