Skip to content

Exploring Tiered Storage

Tiered storage is a brownfield retrofit: you begin with a plain PostgreSQL database full of data, add ColdFront to it, and let the archiver relocate the cold majority to object storage - without migrating to a new database or changing a line of application SQL.

Run the steps in Setting Up the Stack before you start this demo.

The following table shows the eleven steps this demo covers:

Step What you will do
1. Start the stack Bring up Postgres, Lakekeeper, and the object store.
2. Create a table and load history Create an ordinary partitioned table with months of data.
3. See the problem See that all rows sit in hot Postgres storage, which only grows.
4. Enable the extensions Enable the two extensions that retrofit tiering onto the existing database.
5. Point at object storage Tell ColdFront where cold data lives.
6. Show the archiver policy Review the policy, where hot_period: 30 days sets the hot/cold boundary.
7. Run the archiver Move everything older than 30 days to object storage.
8. Where it lives now Inspect the hot/cold split: the rows and space in each tier.
9. Query across tiers Run one query on one table that reads hot and cold data together.
10. Write to cold data UPDATE an archived row in place, with no rehydration.
11. Confirm the edit persisted Reconnect and confirm the edit persisted in cold storage.

Starting the Stack

Setup starts the infrastructure: PostgreSQL (your database), plus Lakekeeper and SeaweedFS. The cold-storage side sits idle until you point ColdFront at it in Step 5. Run the docker compose up command shown in Setting Up the Stack. Then confirm that the db, lakekeeper-db, and lakekeeper services report healthy:

docker compose -f examples/walkthrough/docker-compose.yml ps

PostgreSQL is your existing database. The catalog and object store are also running - that is the cold-storage side, unused until Step 5. Locally the store is SeaweedFS; in production it is AWS S3, GCS, or Azure ADLS Gen2.

Creating a Table and Loading Months of History

This step stands in for the database you already run: an ordinary range-partitioned PostgreSQL table, filled with months of accumulated data. Nothing here is ColdFront-specific.

If you are running the cells from this page, skip ahead - every SQL cell below opens its own connection. Following along in a local shell instead, connect once with the walkthrough credentials (notices suppressed so the output stays clean) and paste each SQL block into the session:

PGOPTIONS='-c client_min_messages=warning' \
  psql -h localhost -p 5432 -U coldfront -d coldfront

Create the partitioned table with 25 monthly partitions covering about two years of history plus the current month:

SET search_path = public;

CREATE TABLE events (
    id     bigint GENERATED BY DEFAULT AS IDENTITY,
    ts     timestamptz NOT NULL,
    status text,
    data   jsonb,
    PRIMARY KEY (id, ts)
) PARTITION BY RANGE (ts);

DO $do$
DECLARE m date;
BEGIN
  FOR i IN 0..24 LOOP
    m := (
      date_trunc('month', now())
      - make_interval(months => 24 - i)
    )::date;
    EXECUTE format(
      'CREATE TABLE IF NOT EXISTS %I'
      ' PARTITION OF events'
      ' FOR VALUES FROM (%L) TO (%L)',
      'events_p_' || to_char(m, 'YYYY_MM'),
      m,
      (m + interval '1 month')
    );
  END LOOP;
END $do$;

The loop creates 25 monthly partitions spanning about two years. Months older than 30 days will tier to cold; the last month or two stays hot.

Insert approximately one million rows spread evenly across the partition window (roughly two years, 730 days, from now()):

INSERT INTO events (id, ts, status, data)
SELECT i,
       now() - ((1000000 - i) * (interval '730 days' / 1000000)),
       (ARRAY['ok','warn','error'])[1 + i % 3],
       jsonb_build_object('level', (ARRAY['info','warn','error'])[1 + i % 3],
                          'region', 'us-east-1', 'latency_ms', (i % 500))
FROM generate_series(1, 1000000) i;

The interactive guide asks for the row count instead of loading one million rows. It offers a suggested size that fits Docker's free disk with headroom (the default), 1M, 10M, or 50M rows, or a custom count. When a size would not fit, the guide warns and asks again.

Confirm all rows landed:

SELECT count(*) FROM events;

The count confirms that all rows landed:

  count
---------
 1000000

Months of data now live in an ordinary PostgreSQL table - no ColdFront involved yet.

Seeing the Problem

This step measures how much hot Postgres storage that data occupies and confirms that none of it is in lower-cost storage yet. This is the baseline you will compare against after tiering in Step 8.

pg_total_relation_size() on a partitioned parent counts only the empty parent itself and reports zero. Sum across pg_partition_tree to get the true heap size:

SELECT pg_size_pretty(
    pg_total_relation_size('events') +
    COALESCE((
        SELECT sum(pg_total_relation_size(relid))
        FROM pg_partition_tree('events')
        WHERE relid <> 'events'::regclass
    ), 0)
) AS hot_size;

The query returns:

 hot_size
----------
 152 MB

Every row - all one million - occupies hot, expensive primary storage, and the table only grows. Remember this figure; Step 8 shows where it goes after tiering.

Enabling the Extensions

This step retrofits tiering onto the database you already have, using two extensions. pg_duckdb gives PostgreSQL an in-process engine that can read Parquet in object storage. coldfront adds the layer that routes each query to the right tier and rewrites DML. There is no migration and no new database - these install onto the running one.

Run the following SQL to create both extensions:

CREATE EXTENSION IF NOT EXISTS pg_duckdb;
CREATE EXTENSION IF NOT EXISTS coldfront;

Confirm both are installed:

\dx

The output lists both:

                                                            List of installed extensions
   Name    | Version | Default version |   Schema   |                                          Description
-----------+---------+-----------------+------------+------------------------------------------------------------------------------------------------
 coldfront | 1.0     | 1.0             | coldfront  | Transparent tiered storage: route DML on tiered views across hot (PG) and cold (Iceberg) tiers
 pg_duckdb | 1.1.0   | 1.1.0           | public     | DuckDB Embedded in Postgres
 plpgsql   | 1.0     | 1.0             | pg_catalog | PL/pgSQL procedural language
(3 rows)

Two extensions - that is the entire ColdFront install. There is no sidecar, no proxy, and no data movement yet.

Pointing ColdFront at the Object Store

This step tells ColdFront where cold data goes and how to authenticate to it. The credentials below are throwaway values for the local SeaweedFS emulator. In production, pass your real bucket's key, secret, and endpoint here - application SQL is unchanged when you swap stores.

Register the local SeaweedFS credentials:

SELECT coldfront.set_storage_secret(
  'admin', 'adminsecret', 'seaweedfs:8333'
);

With the credential stored, confirm the warehouse that setup created (wh) exists:

curl -s localhost:8181/management/v1/warehouse \
  | grep -o '"name":"wh"'

The response confirms that the warehouse exists:

"name":"wh"

Showing the Archiver Policy

The archiver policy is one rule in a YAML file: data older than 30 days belongs in low-cost object storage; the most recent data stays hot in PostgreSQL. Nothing moves yet - this step shows the boundary.

Inspect the archiver configuration:

cat examples/walkthrough/config/archiver.yaml

The file reads:

# ColdFront walkthrough archiver config: Demo 1 (tiered).
#
# An import input: `docker compose run --rm --no-deps archiver import --config
# /config/archiver.yaml` writes it into the server once, the table into
# coldfront.partition_config and the store into coldfront.storage_secret. The
# archiver then runs with no file, connecting from the PG* environment the
# compose service sets. Endpoints are compose SERVICE NAMES, since the archiver
# runs inside the compose network. hot_period "30 days" keeps the current month
# hot and tiers the older months to Iceberg/S3, deterministic because the
# walkthrough seeds now()-relative timestamps.
s3:
    endpoint: "seaweedfs:8333"
    region: "us-east-1"
    access_key: "admin"
    secret_key: "adminsecret"
    use_ssl: false
    url_style: "path"
archiver:
    tables:
        - source_table: events
          partition_period: monthly
          hot_period: "30 days"

The hot_period: 30 days value is the hot/cold line. Any partition whose data is entirely older than 30 days will move to object storage when the archiver runs.

Registering the Table, Then Running the Archiver

The archiver reads its configuration from the server. import writes the YAML into it once: the table into coldfront.partition_config and the store into coldfront.storage_secret. Then run the archiver, which connects from the PG* environment the compose service sets and needs no file: it moves every partition older than 30 days out of the PostgreSQL heap into Parquet files in object storage and rebuilds events as a unified view over the hot remainder and the cold data.

The following commands import the table and run the archiver:

docker compose \
  -f examples/walkthrough/docker-compose.yml \
  run --rm --no-deps archiver import --config /config/archiver.yaml

docker compose \
  -f examples/walkthrough/docker-compose.yml \
  run --rm --no-deps archiver

The --no-deps flag reuses the already-running, data-loaded db container; without it, docker compose run re-evaluates depends_on and can recreate db from its config hash, restarting the database in the middle of the demo (the pgdata volume keeps the rows).

The archiver connects inside the Compose network (service name db, not localhost), detaches the partitions older than 30 days from PostgreSQL, exports them to Iceberg via pg_duckdb, and replaces the events table with a unified view that queries both tiers.

Proving the Cold Rows Are Really in S3 as Parquet

The iceberg_metadata() table function resolves its argument as a filesystem path, so a REST-catalog table cannot be addressed by name directly. Resolve the table's metadata.json S3 location from the Lakekeeper catalog first, then point iceberg_metadata() at that path:

# Resolve the warehouse id and the table's metadata location.
WH_ID=$(curl -s http://localhost:8181/management/v1/warehouse \
  | grep -o '"warehouse-id":"[^"]*"' \
  | head -1 | cut -d'"' -f4)

META_LOC=$(curl -s \
  "http://localhost:8181/catalog/v1/${WH_ID}/namespaces/public/tables/events" \
  -H 'accept: application/json' \
  | grep -o '"metadata-location":"[^"]*"' \
  | head -1 | cut -d'"' -f4)

echo "$META_LOC"

# Query the Parquet data files registered in that Iceberg snapshot.
psql "postgresql://coldfront@localhost:5432/coldfront" \
  -v ON_ERROR_STOP=1 -P pager=off <<SQL
SELECT file_path
FROM iceberg_metadata('${META_LOC}')
WHERE file_path LIKE '%.parquet'
LIMIT 3;
SQL

The query returns:

s3://iceberg/<table-uuid>/metadata/00023-<uuid>.gz.metadata.json

 file_path
-----------------------------------------------------------------
 s3://iceberg/.../data/month_ts_2=656/01a0ec3d-8abb-....parquet
 s3://iceberg/.../data/month_ts_2=657/01a0ec3d-8b52-....parquet
 s3://iceberg/.../data/month_ts_2=658/01a0ec3d-8bd4-....parquet

Real .parquet objects are in the bucket. The cold rows are no longer in PostgreSQL - they are objects in object storage. Each month's files sit under their own month_ts_<n>= directory: the cold table is partitioned by month, the way the hot table is.

Proving the Table Changed Shape

Inspect the relation type:

\d events

events is now a view. The _events table holds only the hot remainder. Run the following query to see the recorded hot/cold cutoff:

SELECT * FROM coldfront.archive_watermark;

Checking Where the Data Lives Now

This step accounts for every row after tiering: how many are still hot in PostgreSQL, how many are now cold in object storage, and how much space each tier uses. This is the direct payoff against the Step 3 baseline.

Count the hot rows still in the PostgreSQL heap:

SELECT count(*) AS hot_rows FROM _events;

Measure the hot heap size (sum over the partition tree, as in Step 3):

SELECT pg_size_pretty(
    pg_total_relation_size('_events') +
    COALESCE((
        SELECT sum(pg_total_relation_size(relid))
        FROM pg_partition_tree('_events')
        WHERE relid <> '_events'::regclass
    ), 0)
) AS hot_size;

Count the total rows across both tiers and derive the cold count:

SELECT count(*) AS total_rows FROM events;

Together, the three queries give the following hot/cold split:

  Tier                    Rows         Postgres heap
  ----------------------  -----------  ----------------
  Hot  (Postgres)             ~42,000  ~6.6 MB
  Cold (Parquet in S3)       ~958,000  0 bytes in PG
  ----------------------  -----------  ----------------
  Total                     1,000,000

The hot share depends on the day you run the demo. The archiver moves a monthly partition to cold storage once its end is at least 30 days in the past, so the previous month stays hot until the 31st. A run on the 1st or the 31st keeps about 42,000 rows hot, and a run late in any other month about 83,000. Before tiering (Step 3), the table held one million rows, all hot, in approximately 152 MB of Postgres heap. After tiering, only the last month or two remains in PostgreSQL; over 90% of the hot storage is gone while the total row count is unchanged.

Querying Across Tiers

This step runs one ordinary query against events that spans both tiers, then queries the hot-only table for contrast. The application issuing this query cannot tell which rows came from the heap and which came from object storage.

Query the whole table - hot and cold together:

SELECT count(*) AS total FROM events;

Query only the hot heap:

SELECT count(*) AS hot FROM _events;

Retrieve specific rows from a cold month:

SELECT id, ts, status
FROM events
WHERE ts < date_trunc('month', now()) - interval '3 months'
ORDER BY ts
LIMIT 3;

The query returns:

 id |              ts               | status
----+-------------------------------+--------
  1 | 2024-10-01 14:44:24.687844+00 | warn
  2 | 2024-10-01 14:45:27.759844+00 | error
  3 | 2024-10-01 14:46:30.831844+00 | ok
(3 rows)

The timestamps track your load: the rows are 63.072 seconds apart (730 days divided by one million), and row 1 sits one such step short of 730 days before the load. It is one table and one query, and no application change is required.

Writing to Cold Data

This step takes a specific row from a cold (archived) month and updates it through the same events view. Watch for what does not happen: no rehydration of the partition back into PostgreSQL, no ETL job, no restore from archive, and no second tool.

Capture a cold row's id in a separate query. A sub-select over the tiered view inside the same DML statement is rejected by the extension, because the rewrite retargets the leading reference:

SELECT id, ts, status
FROM events
WHERE ts < date_trunc('month', now()) - interval '2 months'
ORDER BY ts
LIMIT 1;

The query returns:

 id |              ts               | status
----+-------------------------------+--------
  1 | 2024-10-01 14:44:24.687844+00 | warn

Update the archived row through the same table using the captured id:

UPDATE events SET status = 'corrected' WHERE id = 1;

Read the row back immediately:

SELECT id, ts, status FROM events WHERE id = 1;

The updated row reads:

 id |              ts               |  status
----+-------------------------------+-----------
  1 | 2024-10-01 14:44:24.687844+00 | corrected

The row's status flipped warn to corrected. That row is still sitting in object storage - ColdFront wrote through to it directly.

Confirming the Edit Persisted

This step opens a fresh psql connection (nothing cached from the session that did the write) and re-checks the row, the total row count, and the hot heap size. This confirms that the cold edit is durable persistent state and that the data did not quietly return to PostgreSQL to make the edit possible.

Each runnable cell on this page already opens a fresh connection, so cell-runners can run the checks below. In a local shell, open a new terminal and connect with a fresh session first:

PGOPTIONS='-c client_min_messages=warning' \
  psql -h localhost -p 5432 -U coldfront -d coldfront

Confirm the archived row is still corrected:

SELECT id, status FROM events WHERE id = 1;

The row reads:

 id |  status
----+-----------
  1 | corrected
(1 row)

Confirm the total row count is unchanged:

SELECT count(*) AS total_rows FROM events;

The count confirms the total is unchanged:

 total_rows
------------
    1000000

Confirm the hot heap is still small (the data did not rehydrate):

SELECT pg_size_pretty(
    pg_total_relation_size('_events') +
    COALESCE((
        SELECT sum(pg_total_relation_size(relid))
        FROM pg_partition_tree('_events')
        WHERE relid <> '_events'::regclass
    ), 0)
) AS hot_size;

The query returns:

 hot_size
----------
 6616 kB
(1 row)

In the fresh connection, the archived row is still corrected, all one million rows are present, and the hot heap is still small. The data never came back to PostgreSQL. Over 90% of the original storage now lives in object storage as Parquet, and that data is still a normal, writeable part of the table - corrected in place, no rehydration, no separate system.

Next Steps

To go further with ColdFront, consult the following guides:

  • The Decoupled Mode Demo stores a table in Iceberg from the first row and adopts a table that another engine wrote.
  • The Object Store Setup guide takes you from an empty bucket to a working cold tier on AWS S3.
  • The Architecture overview explains the shared mechanics and links to the per-mode deep dives.
  • The Tearing Down the Stack section describes how to stop the stack and remove its data.