Jaco van der Laan

Sidecar Tables: Companion Metadata for Data Assets

05/03/2026  ·  Data Engineering  ·  Metadata

metadata quality
‹ ›

How auxiliary tables extend your data model without polluting your core schema


Sidecar Tables: Companion Metadata for Data Assets

** How auxiliary tables extend your data model without polluting your core schema**


Introduction

In data engineering, we often need to track information about our data that doesn’t belong in the data itself. History of changes. Quality scores. Audit trails. Load statistics. Version information.

The naive approach is to add columns directly to the main table:

  • created_at, updated_at, created_by
  • quality_score, last_checked_at
  • version_number, previous_value

But this quickly becomes unwieldy. Your clean business entity becomes bloated with operational metadata. Your ETL pipelines now need to populate a dozen extra columns. Your consumers sift through fields they don’t care about.

Sidecar tables solve this by separating concerns: the main table holds business data, while companion tables hold operational metadata.


What is a Sidecar Table?

A sidecar table is a companion table that:

  1. References the main table via foreign key
  2. Tracks metadata that doesn’t belong in the main entity
  3. Has its own lifecycle (can be truncated, archived, or dropped independently)
  4. Follows naming conventions that make the relationship obvious

The term comes from the “sidecar pattern” in microservices architecture, where a helper container runs alongside the main application container, handling cross-cutting concerns like logging, monitoring, and security.

┌──────────────────────────────────────────────────────────────────────┐  
│                          DATA ASSET                                  │  
│                                                                      │  
│   ┌─────────────────────┐     ┌─────────────────────────────────┐    │  
│   │     Main Table      │     │        Sidecar Tables           │    │  
│   │                     │     │                                 │    │  
│   │  • Business data    │────►│  • _version   (SCD2 history)    │    │  
│   │  • Core attributes  │     │  • _history   (change audit)    │    │  
│   │  • Primary keys     │     │  • _quality   (DQ metrics)      │    │  
│   │                     │     │  • _log       (load tracking)   │    │  
│   └─────────────────────┘     │  • _audit     (access tracking) │    │  
│                               │  • _config    (runtime params)  │    │  
│                               └─────────────────────────────────┘    │  
└──────────────────────────────────────────────────────────────────────┘

Sidecar Table Categories

1. Version Tables (_version)

Track the complete history of an entity using SCD Type 2 semantics.

Purpose : Answer “What did this record look like at time X?”

Structure :

entity_version:  
  attributes:  
    - version_id: VARCHAR (PK)  
    - entity_id: VARCHAR (FK to main table)  
    - version_sequence: INTEGER  
    - valid_from: TIMESTAMP  
    - valid_to: TIMESTAMP  
    - is_current: BOOLEAN  
    - changed_by: VARCHAR  
    - change_reason: VARCHAR  
    # ... snapshot of all versioned attributes

Example :

-- Main table  
CREATE TABLE customer (  
    customer_id VARCHAR PRIMARY KEY,  
    name VARCHAR,  
    email VARCHAR,  
    segment VARCHAR  
);

-- Version sidecar  
CREATE TABLE customer_version (  
    version_id VARCHAR PRIMARY KEY,  
    customer_id VARCHAR REFERENCES customer(customer_id),  
    version_sequence INTEGER,  
    name VARCHAR,  
    email VARCHAR,  
    segment VARCHAR,  
    valid_from TIMESTAMP,  
    valid_to TIMESTAMP,  
    is_current BOOLEAN,  
    changed_by VARCHAR  
);

When to use :

  • Regulatory requirements for audit trails
  • Time-travel queries
  • Debugging data issues

2. History Tables (_history)

Track specific changes to attributes or status transitions.

Purpose : Answer “What changed, when, and why?”

Structure :

entity_status_history:  
  attributes:  
    - history_id: VARCHAR (PK)  
    - entity_id: VARCHAR (FK)  
    - status_dimension: VARCHAR  # Which status changed  
    - old_value: VARCHAR  
    - new_value: VARCHAR  
    - changed_at: TIMESTAMP  
    - changed_by: VARCHAR  
    - change_reason: VARCHAR

Example :

-- Track status transitions for orders  
CREATE TABLE order_status_history (  
    history_id VARCHAR PRIMARY KEY,  
    order_id VARCHAR REFERENCES order(order_id),  
    old_status VARCHAR,  
    new_status VARCHAR,  
    changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,  
    changed_by VARCHAR,  
    change_reason VARCHAR  
);

Difference from**_version**:

  • _version: Full row snapshots, answers “what was the complete state?”
  • _history: Change events, answers “what specifically changed?”

3. Quality Tables (_quality, _quality_sidecar)

Store data quality metrics and attestations.

Purpose : Answer “How trustworthy is this data?”

Structure :

entity_quality_sidecar:  
  attributes:  
    - batch_id: VARCHAR (PK)  
    - attestation_id: VARCHAR  
    - load_timestamp: TIMESTAMP  
    - row_count: INTEGER  
    - overall_status: VARCHAR      # PASSED, WARNING, FAILED  
    - overall_score: DECIMAL  
    - completeness_score: DECIMAL  
    - uniqueness_score: DECIMAL  
    - validity_score: DECIMAL  
    - passed_checks: INTEGER  
    - failed_checks: INTEGER  
    - attestation_json: JSON       # Full attestation detail

Example :

-- Quality sidecar for transactions  
CREATE TABLE fct_transactions_quality_sidecar (  
    batch_id VARCHAR PRIMARY KEY,  
    load_timestamp TIMESTAMP,  
    row_count INTEGER,  
    overall_status VARCHAR,  
    overall_score DECIMAL(5,4),  
    completeness_score DECIMAL(5,4),  
    uniqueness_score DECIMAL(5,4),  
    total_checks INTEGER,  
    passed_checks INTEGER,  
    attestation_json JSON  
);

-- Query: "Should I trust today's data?"  
SELECT batch_id, overall_status, overall_score  
FROM fct_transactions_quality_sidecar  
WHERE load_timestamp >= CURRENT_DATE  
  AND overall_status = 'PASSED';

4. Log Tables (_log)

Track operational events: loads, deployments, executions.

Purpose : Answer “What happened during processing?”

Structure :

entity_load_history:  
  attributes:  
    - load_id: VARCHAR (PK)  
    - entity_id: VARCHAR (FK)  
    - batch_id: VARCHAR  
    - load_start: TIMESTAMP  
    - load_end: TIMESTAMP  
    - rows_inserted: INTEGER  
    - rows_updated: INTEGER  
    - rows_deleted: INTEGER  
    - status: VARCHAR              # SUCCESS, FAILED, PARTIAL  
    - error_message: VARCHAR

Example :

-- Deployment log for models  
CREATE TABLE model_deployment_log (  
    deployment_id VARCHAR PRIMARY KEY,  
    model_id VARCHAR,  
    version_tag VARCHAR,  
    environment VARCHAR,  
    deployed_at TIMESTAMP,  
    deployed_by VARCHAR,  
    deployment_ticket VARCHAR,  
    status VARCHAR  
);

5. Audit Tables (_audit)

Track who accessed or modified data and when.

Purpose : Answer “Who did what and when?” (compliance, security)

Structure :

entity_audit:  
  attributes:  
    - audit_id: VARCHAR (PK)  
    - entity_id: VARCHAR (FK)  
    - action: VARCHAR              # SELECT, INSERT, UPDATE, DELETE  
    - performed_by: VARCHAR  
    - performed_at: TIMESTAMP  
    - client_ip: VARCHAR  
    - session_id: VARCHAR  
    - affected_rows: INTEGER

6. Configuration Tables (_config, _def)

Store runtime parameters and definitions.

Purpose : Answer “How should this entity behave?”

Structure :

entity_config:  
  attributes:  
    - config_id: VARCHAR (PK)  
    - entity_id: VARCHAR (FK)  
    - config_key: VARCHAR  
    - config_value: VARCHAR  
    - effective_from: TIMESTAMP  
    - effective_to: TIMESTAMP

Example :

-- PIT generation configuration  
CREATE TABLE dim_customer_pit_config (  
    config_id VARCHAR PRIMARY KEY,  
    entity_id VARCHAR,  
    pit_generation_enabled BOOLEAN,  
    snapshot_frequency VARCHAR,  
    retention_days INTEGER  
);

Naming Conventions

MDDE follows consistent naming patterns for sidecar tables:

Full Naming Pattern :

{main_entity}_{sidecar_type}

Examples:

  • customer → customer_version, customer_history, customer_quality_sidecar
  • fct_orders → fct_orders_version, fct_orders_load_history
  • dim_product → dim_product_status_history, dim_product_quality_sidecar

Multiple Sidecars per Main Table

A single main table can have multiple sidecar tables, each serving different purposes:

                    ┌─────────────────────────┐  
                    │      dim_customer       │  
                    │  (Main Business Table)  │  
                    │                         │  
                    │  customer_id (PK)       │  
                    │  name                   │  
                    │  email                  │  
                    │  segment                │  
                    └───────────┬─────────────┘  
                                │  
         ┌──────────────────────┼──────────────────────┐  
         │                      │                      │  
         ▼                      ▼                      ▼  
┌─────────────────┐   ┌─────────────────┐   ┌─────────────────┐  
│ dim_customer_   │   │ dim_customer_   │   │ dim_customer_   │  
│ version         │   │ status_history  │   │ quality_sidecar │  
│                 │   │                 │   │                 │  
│ Full SCD2       │   │ Segment changes │   │ DQ scores per   │  
│ snapshots       │   │ over time       │   │ load batch      │  
└─────────────────┘   └─────────────────┘   └─────────────────┘  
         │  
         ▼  
┌─────────────────┐  
│ dim_customer_   │  
│ load_history    │  
│                 │  
│ ETL execution   │  
│ statistics      │  
└─────────────────┘

MDDE’s Internal Model Example :

The entity table (core metadata) has multiple sidecars:


Implementation Patterns

Pattern 1: Foreign Key Reference

The simplest pattern — sidecar references main table:

CREATE TABLE customer_version (  
    version_id VARCHAR PRIMARY KEY,  
    customer_id VARCHAR NOT NULL REFERENCES customer(customer_id),  
    -- versioned attributes...  
);

Pros : Strong referential integrity Cons : Cannot create sidecar record before main record exists


Pattern 2: Loose Coupling

Sidecar stores the reference without FK constraint:

CREATE TABLE customer_quality_sidecar (  
    batch_id VARCHAR PRIMARY KEY,  
    entity_name VARCHAR NOT NULL,  -- 'customer'  
    -- quality metrics...  
);

Pros : Sidecar can exist independently, works for external data Cons : No referential integrity guarantee


Pattern 3: Composite Key Reference

For sidecars that track attributes:

CREATE TABLE attribute_propagation_history (  
    history_id VARCHAR PRIMARY KEY,  
    entity_id VARCHAR NOT NULL,  
    attribute_id VARCHAR NOT NULL,  
    -- history details...  
    FOREIGN KEY (entity_id, attribute_id)  
        REFERENCES attribute(entity_id, attribute_id)  
);

Pattern 4: JSON Sidecar Column

Sometimes a full sidecar table is overkill. Embed as JSON:

CREATE TABLE fct_transactions (  
    transaction_id VARCHAR PRIMARY KEY,  
    amount DECIMAL(18,2),  
    -- business columns...  
    -- Embedded sidecar as JSON  
    _quality_attestation JSON,  
    _lineage_metadata JSON  
);

Pros : No join needed, data travels together Cons : Harder to query, index, and maintain


When to Use Sidecar Tables

Use Sidecars When:

  1. Separation of concerns — Operational metadata shouldn’t pollute business data
  2. Different lifecycles — You may want to archive/purge sidecars independently
  3. Different access patterns — Consumers rarely need metadata
  4. Compliance requirements — Audit trails must be immutable
  5. Multiple metadata types — One main table needs version + quality + audit

Avoid Sidecars When:

  1. Simple audit columns suffice — created_at, updated_at cover your needs
  2. Performance critical — Every join adds latency
  3. Metadata always needed — If consumers always need quality scores, embed them
  4. Low volume — Overhead not justified for small tables

MDDE’s Sidecar Implementation

MDDE uses sidecars extensively in its internal metadata model:

Versioning Sidecars

models/versioning/entities/  
├── attribute_version/      # Attribute definition history  
├── entity_version/         # Entity definition history  
├── model_version/          # Model definition history  
├── relationship_version/   # Relationship definition history  
├── schema_version/         # Schema definition history  
├── object_version/         # Generic object versioning  
├── change_log/             # Change event log  
├── deletion_audit/         # Soft delete tracking  
└── domain_version_history/ # Domain value changes

History Sidecars

models/governance/entities/  
├── entity_status_history/  # Lifecycle status transitions  
├── compliance_history/     # Compliance check results  
├── spec_history/           # Specification change history  
└── survivorship_audit/     # MDM survivorship decisions

models/operations/entities/  
├── entity_load_history/    # ETL load statistics  
├── effectivity_history/    # Effectivity period changes  
├── quality_metric_history/ # DQ metric time series  
└── quality_kpi_history/    # Aggregated quality KPIs

Log Sidecars

models/operations/entities/  
├── model_deployment_log/   # Deployment events  
├── run_log/                # Pipeline execution log  
└── sql_generation_run/     # SQL generation events

models/work/entities/  
└── work_action_log/        # User action audit

Quality Sidecar in Detail

The quality sidecar pattern (ADR-371) deserves special attention as it enables data trust decisions :

-- Producer creates quality attestation  
INSERT INTO fct_transactions_quality_sidecar (  
    batch_id,  
    load_timestamp,  
    row_count,  
    overall_status,  
    overall_score,  
    completeness_score,  
    uniqueness_score,  
    attestation_json  
) VALUES (  
    'batch_20260301_001',  
    '2026-03-01 10:00:00',  
    1500000,  
    'PASSED',  
    0.9875,  
    0.9950,  
    1.0000,  
    '{"checks": [...], "environment": "PROD", "producer_version": "v2.1.0"}'  
);

-- Consumer queries quality before using data  
SELECT t.*, q.overall_status, q.overall_score  
FROM fct_transactions t  
JOIN fct_transactions_quality_sidecar q ON t.batch_id = q.batch_id  
WHERE q.overall_status = 'PASSED'  
  AND q.overall_score >= 0.95;

Quality travels with data — Consumers can make informed decisions about data trustworthiness.


Best Practices

1. Consistent Naming

Follow the {main_entity}_{sidecar_type} pattern religiously:

✅ customer_version  
✅ customer_status_history  
✅ customer_quality_sidecar

❌ customer_hist           # Unclear abbreviation  
❌ version_customer        # Wrong order  
❌ tbl_customer_versions   # Hungarian notation

2. Common Columns

Include standard columns in all sidecars:

# Origin tracking  
origin_id: VARCHAR      # Source of this record  
origin_type: VARCHAR    # yaml_import, sql_parser, api, etc.

# Audit tracking  
created_at: TIMESTAMP DEFAULT CURRENT_TIMESTAMP

3. Indexing Strategy

Index the foreign key and common query patterns:

-- Always index the FK  
CREATE INDEX idx_customer_version_customer_id  
    ON customer_version(customer_id);

-- Index temporal queries  
CREATE INDEX idx_customer_version_valid_from  
    ON customer_version(valid_from);

-- Index current record lookup  
CREATE INDEX idx_customer_version_current  
    ON customer_version(customer_id, is_current)  
    WHERE is_current = TRUE;

4. Retention Policies

Define retention independently for sidecars:

retention_policies:  
  customer:  
    retention: forever          # Business data kept indefinitely  
  customer_version:  
    retention: 7_years          # Regulatory requirement  
  customer_quality_sidecar:  
    retention: 90_days          # Operational data, shorter retention  
  customer_load_history:  
    retention: 30_days          # Debug data, shortest retention

5. Documentation

Document the relationship in metadata:

# customer.yaml  
entity:  
  name: customer  
  sidecars:  
    - type: version  
      table: customer_version  
      purpose: SCD2 history for regulatory compliance  
    - type: quality  
      table: customer_quality_sidecar  
      purpose: DQ attestation per load batch  
    - type: history  
      table: customer_status_history  
      purpose: Segment transition audit

Querying Sidecars

Point-in-Time Query (Version Sidecar)

-- What was customer 123's segment on Jan 1, 2026?  
SELECT cv.segment  
FROM customer_version cv  
WHERE cv.customer_id = '123'  
  AND cv.valid_from <= '2026-01-01'  
  AND (cv.valid_to > '2026-01-01' OR cv.valid_to IS NULL);

Change History Query (History Sidecar)

-- Show all segment changes for customer 123  
SELECT  
    old_value AS previous_segment,  
    new_value AS new_segment,  
    changed_at,  
    change_reason  
FROM customer_status_history  
WHERE entity_id = '123'  
  AND status_dimension = 'segment'  
ORDER BY changed_at;

Quality-Gated Query (Quality Sidecar)

-- Only use data from batches that passed quality  
SELECT t.*  
FROM fct_transactions t  
JOIN fct_transactions_quality_sidecar q  
    ON t.batch_id = q.batch_id  
WHERE q.overall_status = 'PASSED'  
  AND q.completeness_score >= 0.99;

Load Statistics Query (Log Sidecar)

-- Recent load performance  
SELECT  
    load_start,  
    load_end,  
    load_end - load_start AS duration,  
    rows_inserted,  
    rows_updated,  
    status  
FROM customer_load_history  
WHERE load_start >= CURRENT_DATE - INTERVAL '7 days'  
ORDER BY load_start DESC;

Conclusion

Sidecar tables are a powerful pattern for managing metadata without polluting your core data model. By following consistent naming conventions and understanding when to use each sidecar type, you can:

  1. Keep business tables clean — Only business data in business tables
  2. Enable time-travel — Full historical audit trails
  3. Build trust — Quality attestations travel with data
  4. Debug efficiently — Load logs and operational metrics at your fingertips
  5. Comply confidently — Immutable audit trails for regulatory requirements

The key insight is that metadata about data is also data — it deserves its own proper schema design, not just extra columns tacked onto the main table.


References


Part of the MDDE series on metadata-driven data engineering.

By Jaco van der Laan on March 5, 2026.

Canonical link

Exported from Medium on March 29, 2026.