Showing posts with label database. Show all posts
Showing posts with label database. Show all posts

CDC at WHOOP: Self-Service Replication for Hundreds of Postgres Tables

Mike's Notes

This article was mentioned in a recent Data Engineering Weekly from Ananth Packkildurai.

"WHOOP writes about replacing costly database replication that required data engineers to handle table and column changes. Its system records changes continuously, updates analytical copies every four hours, and automatically carries new tables and columns downstream. This reduces computing costs and lets service teams manage replication themselves, while teams needing fresher data can read the change records directly." - Ananth Packkildurai

Lots of great lessons here about implementing Change Data Capture (CDC).😎

Resources

References

  • Reference

Repository

  • Home > Ajabbi Research > Library >Subscriptions > Data Engineering Weekly
  • Home > Handbook > 

Last Updated

21/10/2026

CDC at WHOOP: Self-Service Replication for Hundreds of Postgres Tables

By: Jack Leitch
WHOOP: 26/09/2026

Jack Leitch: Staff Software Engineer, Data Platform.

...

A software engineer at WHOOP adds a new column to a Postgres table on Monday morning. By that afternoon, the column is queryable in Snowflake and available to any downstream model or dashboard, alongside every other column on that table. Nobody filed a ticket. No data engineer touched anything.

Change Data Capture (CDC) is the pattern that makes this possible: every insert, update, and delete on a source table is streamed out as an event, and downstream systems consume that stream to stay in sync. That workflow is the point of the CDC platform we run today, and getting there meant deliberately avoiding the streaming-upsert pattern most CDC-to-lake pipelines converge on.

The system it replaced

Debezium captured row changes from Postgres and published them to Kafka as JSON, with no schema registry sitting in front of it, so downstream consumers were reading loosely typed payloads. A Spark Structured Streaming job then upserted those events into Iceberg tables on a thirty-minute trigger. It worked. It also had four problems that got worse as WHOOP grew:

  1. The upserts were expensive. We ran one Spark Structured Streaming job per table, so hundreds of long-running streaming jobs sat continuously reconciling their targets on a thirty-minute cadence. These tables were queried heavily and there was real value in keeping them fresh, but the compute cost of continuously running one streaming job per table was the dominant issue: we were paying a lot for freshness that most consumers did not strictly need.
  2. Schema changes were manual. A new Postgres column would not appear downstream until someone filed a ticket, and the fix usually meant backfilling the entire table. Every schema change turned into a coordination problem across teams.
  3. Observability was thin. We had Spark job metrics and Postgres-side replication slot lag, but nothing meaningful from Debezium itself. No per-connector throughput, no per-connector lag, no visibility into what the source connector was actually doing.
  4. Per-table configuration lived in the wrong place. Kafka partitioning, topic settings, and the list of replicated tables did live in the owning service's own repo, but they lived separately from the normal Postgres config and Liquibase migrations that engineers touched day to day. So they got forgotten. New tables would land in Postgres and quietly not make it into CDC, sometimes for months.

The pipeline itself was fine. Its operating model wasn't. Every design choice put Data Platform on the critical path for something a service team should have owned.

The core idea: split streaming from upserts

Most CDC-to-lake systems merge two concerns into one job. They take a stream of change events and, in real time, produce a materialized replica of the source table. That is a legitimate goal, but it forces continuous upserts, which are expensive to run at scale.

We split them apart.

  • A Bronze layer captures every CDC event exactly as Debezium emits it. It is append-only. Small files, cheap writes, no reconciliation.
  • A Silver layer materializes the actual Postgres replica. It reads Bronze and upserts into Iceberg, but only every four hours.

For consumers, this gives two clean options. If you need the latest state of a table and can tolerate a few hours of staleness, you query Silver, and the table looks exactly like the Postgres source. If you need lower latency or you specifically want change semantics (inserts, updates, deletes), you query Bronze and filter by the CDC timestamp.

Very few consumers actually need the latter, so continuous upserts were the wrong default.

End-to-end CDC architecture: Postgres to Debezium to Kafka to Flink to Bronze and Silver Iceberg tables

Debezium and Postgres publications

We run one Debezium connector per Postgres database. Each connector reads from a Postgres publication using logical replication, serializes each event as Avro against the AWS Glue Schema Registry, and publishes one Kafka topic per table using the convention postgres_cdc.<service>.<schema>.<table>.

The set of replicated tables is driven by the Postgres publication itself, and the publication is managed with Liquibase migrations that live inside the service team's own GitHub repository. Adding a table looks like this:

-- Liquibase migration in the service repo
ALTER PUBLICATION cdc_publication ADD TABLE new_feature_table;

Some services skip the per-table dance entirely and declare the publication as FOR ALL TABLES, so anything they add to Postgres is automatically replicated. New tables just appear.

That is the entire ceremony. Debezium picks up publication changes on the next poll. The Flink job downstream discovers the new topic within a minute and starts writing it to Bronze. Silver picks it up on its next run and backfills from the latest snapshot. No PRs on our side, no tickets. The service team owns the decision and the migration is code-reviewed in the same repo as the schema change that motivated it.

The other property that falls out of this design is automatic schema evolution. When Debezium sees a new column, it registers a new Avro schema in Glue. The Flink sink writes the new column to Bronze on the next commit. Silver sees the new field on its next run and adds it to the Iceberg schema. A single ALTER TABLE in Postgres propagates through the entire stack with no human involvement. Supported type changes work the same way: widening a decimal(10,2) to decimal(12,2), for example, flows through Avro's schema evolution rules and Iceberg's promotion rules without breaking any downstream reads.

Each connector deploys through an internal Terraform module that standardizes the operational plumbing: IAM, secrets, Datadog monitors, and the Glue registries. We also build our own Debezium container image, which bundles a small JMX metrics exporter alongside the upstream Debezium jars. That exporter surfaces connector-level metrics in Datadog: replication lag (MilliSecondsBehindSource), events seen, errored tasks, and restarts. Those metrics route to the owning team's oncall, not ours, which was a specific goal. If a service's connector is falling behind, the team that owns the database is the first to know.

Flink: one job per database, fan-in and fan-out

Bronze is written by Flink. Bronze tables follow the convention bronze.<service>.<schema>.<table>, so the Postgres source and its Iceberg replica are directly identifiable from each other's names.

There is one Flink job per Postgres database, and each job uses a fan-in / fan-out pattern: it subscribes to every Kafka topic under that database's prefix, then routes each record to the correct Iceberg table based on the topic name.

One Flink job per database subscribing to N Kafka topics and writing to N Bronze Iceberg tables

A naive design would run one Flink job per table, which means N deployments, N sets of checkpoints, and N connectors to Kafka. For a service with fifty tables, that is fifty jobs to autoscale, monitor, and upgrade. The fan-in/fan-out pattern collapses that to one job per database, regardless of how many tables live in it.

The routing itself is handled by the Dynamic Iceberg Sink introduced in Iceberg 1.10.0. Each record carries its Iceberg table name derived from the source Kafka topic, and the sink handles the rest, including creating new tables when they appear. We rediscover Kafka topics every 60 seconds, so new tables added to a Postgres publication start flowing to Bronze within a minute of the publication change. No job restart is required.

Each Bronze record is the row plus five CDC metadata columns:

Column Meaning
cdc_op c (create), u (update), or d (delete)
cdc_ts Source database event timestamp
cdc_processed_at When Flink processed the record
cdc_source_lsn Postgres WAL log sequence number
cdc_source_tx_id Postgres transaction ID

Bronze tables partition by day(cdc_ts). Compaction runs nightly against the last two days and sorts by cdc_ts DESC, which optimizes for the most common access pattern: "give me the recent changes to this table." Older partitions are already fine.

Jobs deploy through the Flink Kubernetes Operator in Application Mode on our EKS clusters. The operator manages lifecycle, autoscaling, and savepoint-based restarts. An auto-discovery runner scans Glue for new CDC topic prefixes and posts to Slack if it spots a new service without a matching Flink deployment. We deliberately do not fully automate that step today because a handful of services carry PHI and need to be routed to a separate Iceberg catalog. Deciding which catalog a new service belongs in is currently a human call, but it is the kind of thing we plan to automate, with service owners declaring their routing intent at onboarding time rather than pinging us for a decision.

Why Flink instead of the MSK Connect Iceberg sink? We prototyped the MSK Connect option early and it did not hold up at our scale. The connector was still relatively immature, and we hit ceilings on throughput, checkpointing behavior, and observability that were hard to work around. Flink was more operational work to run, but it gave us autoscaling based on backlog, better checkpointing semantics, richer Prometheus metrics, and the ability to add custom routing logic.

Silver: making upserts cheap

Silver is where the actual Postgres replicas live, and it is where the two-layer architecture pays for itself.

Every four hours, a Spark job runs per service. It does three things: figures out what to process, runs MERGE INTO for tables that already exist, and backfills tables that just appeared.

Discovery. The job queries the Glue Schema Registry to find every CDC-enabled table for the service and derives the primary key for each one from the Avro key schema. No manual PK configuration lives anywhere, though we do allow overrides for edge cases (for example, a table with a mutable primary key needs its logical merge key to be declared explicitly).

Backfill (new tables only). For a table that has no Silver counterpart, the job takes the latest automated Aurora snapshot, exports it to S3 in Parquet using Aurora's built-in snapshot export, and merges it into a fresh Silver table. There is one subtle guard here: if the snapshot predates the moment the table was first added to CDC (which we know because Glue records schema creation time), we defer the backfill until a newer snapshot is available. Otherwise we would be seeding from a snapshot that misses events already in Bronze, and the reconciliation from there onward would look correct but silently miss rows.

Incremental (existing tables). The job reads Bronze events since the last watermark, groups by primary key to pick the latest state per row, and executes MERGE INTO on the Silver table. Deletes propagate the same way (via cdc_op = 'd').

Tables get batched into groups of five per Spark job to balance parallelism against resource cost. Each service's Silver flow is independently scheduled.

Four layered optimizations inside the Silver Iceberg table are what let MERGE INTO run in minutes rather than hours. They turn each merge from a full-table shuffle into a targeted, well-pruned operation, and without them Silver would not be cost-viable at our scale.

Bucket partitioning on primary key

Silver tables partition by bucket(N, primary_key), where N is picked per table to target roughly consistent file sizes. Bucket partitioning distributes rows evenly across a fixed number of buckets regardless of PK distribution. That evenness is not just aesthetic. It is a prerequisite for the write-side optimization below.

Sort order and bloom filters within each bucket

Data within each bucket sorts by primary key, and bloom filter indexes are configured on the PK columns. Together they give Spark tight file-level pruning during MERGE: skip any file whose min/max range excludes the incoming PKs, and within a matching file, skip any Parquet row group whose bloom filter says the PK is definitely not present.

The read-side benefit for Snowflake is more limited. Snowflake's Iceberg reader uses min/max file statistics for pruning (which is why sorting matters) but doesn't currently use Parquet bloom filters. So the bloom filters are a Spark-side win; the sort order helps both.

Bucket pruning and Storage Partitioned Join

Two related optimizations do most of the write-side work. They're often talked about together but they are technically distinct.

The first is bucket pruning. When Spark plans a MERGE INTO between an incoming CDC micro-batch and the Silver table, it hashes the incoming primary keys, figures out which buckets they map to, and reads only those buckets from the target. No full table scan.

A five-key incoming batch hashing to buckets 3, 17, 42, 88, 101, with only those buckets highlighted in the 128-bucket Silver table

The second is Storage Partitioned Join. Because both sides of the merge share the same bucketing scheme, Spark can co-locate rows from the incoming batch with the rows already in the target and skip the shuffle that a normal join would need. Without SPJ, Spark would have to hash-repartition the entire target table to line it up with the incoming batch, which is often the largest single cost of a merge at scale.

Both optimizations depend on the same thing: an evenly bucketed target table. Skewed data breaks both, because a single hot bucket dominates the read on one side and the shuffle-avoidance on the other. That is why bucket partitioning is the foundation for everything else in this section.

SPJ plus bucket pruning are what made Silver viable. Merge jobs that would otherwise scan and reshuffle terabytes now touch a small fraction of the target table, and the runtime savings are order-of-magnitude.

Merge-on-Read

Silver tables use Iceberg's Merge-on-Read mode. Instead of rewriting entire Parquet files when a row updates, Iceberg writes a position delete file that marks which rows are stale. That skips the file rewrite on the write path, which is where most of the MERGE cost would otherwise land. Reads pay a small tax at query time to reconcile data and delete files, which we resolve during nightly compaction. Silver compaction sorts data back by primary key, which restores clean read performance and keeps future SPJ merges efficient.

We can afford these Silver optimizations because Silver runs every four hours rather than continuously. Larger batches amortize the fixed overhead of MERGE planning, and a job that runs six times a day is one we can actually tune, rather than firefight.

For teams that need lower latency, they read Bronze directly and filter on cdc_ts. Recent changes are cheap to scan there because Bronze compaction sorts by cdc_ts DESC and prunes small files aggressively for the last two days.

Audit trails from the same pipeline

CDC turned out to be a natural place to solve a problem that would otherwise need its own audit system.

Services that need audit trails on sensitive tables can opt into emitting audit context alongside their writes. When enabled, the service calls pg_logical_emit_message(true, 'audit', ...) inside the same transaction as the data change, embedding metadata like the acting user ID and the SQL query. The true argument matters: it makes the message transactional, so the message and the row change share the same xid and either commit together or not at all.

Debezium captures both. Flink writes row changes to their normal Bronze table and logical messages to a dedicated pg_logical_messages Iceberg table partitioned by day(cdc_ts) and the source table name. In Snowflake, a complete audit trail is a join:

select data.*, audit.user_id, audit.sql_query
from   postgres_cdc_prod.svc.sensitive_table as data
join   postgres_cdc_prod.svc.pg_logical_messages as audit
  on   data.cdc_source_tx_id = audit.cdc_source_tx_id
where  data.cdc_ts  >= dateadd(day, -30, current_timestamp())
  and  audit.cdc_ts >= dateadd(day, -30, current_timestamp());

Both sides get filtered by cdc_ts so partition pruning fires on the audit table as well as the data table. Postgres's transactional guarantees do the rest: every write to an audited table shares its transaction ID with any audit message emitted in the same commit, so the join gives you the transaction-level context (who, when, why) for any change. What the pipeline itself buys us: no separate audit system, and no risk of the audit trail drifting from the data.

The responsibility model this creates

The technical architecture reflects an organizational choice. Service teams own their data. Data Platform owns the infrastructure.

Component Owner
Debezium connector (via Terraform module) Service team
Publication contents (via Liquibase migration) Service team
Flink Bronze jobs Data Platform
Silver Spark jobs, backfills, maintenance Data Platform Data Platform
Snowflake catalog integration Data Platform Data Platform

Adding a table to CDC involves editing exactly one file, in the service's own repo. Everything downstream follows automatically. When a schema changes, no ticket is filed, because there is nothing for us to do. If we did our jobs right, Data Platform's involvement in the average CDC operation is zero.

For the analytical and modeling tables downstream of this (the ones that need explicit schema control and reproducibility), we manage them through Glacierbase, which handles the versioning problem for tables where controlled schema drift is not acceptable. The two systems complement each other: CDC handles ingestion where drift is expected and automatic; Glacierbase handles curated tables where every change should be a reviewed migration.

What this all buys

The two-layer split was about cost, not elegance. Continuous upserts were expensive, most consumers did not need the freshness they were paying for, and separating Bronze from Silver let us make the expensive operation infrequent and the cheap operation continuous. That in turn made the Silver optimizations worth investing in, because a job that runs six times a day can absorb serious tuning work.

Publication-driven self-service came out of the same accounting. Data Platform was the bottleneck for every schema change across every service, so we moved the source of truth into the service's own Liquibase migrations. New tables and new columns propagate downstream without our involvement.

The engineer from the opening paragraph doesn't need us. That is the whole design.

PostgreSQL for Everything

Mike's Notes

Pipi uses PostgreSQL internally.

Resources

References

  • Reference

Repository

  • Home > Ajabbi Research > Library > Subscriptions > Hybrid Hacker
  • Home > Handbook > 

Last Updated

08/09/2026

PostgreSQL for Everything

By: Raphael Bauer
Raphael Bauer: 14/02/2024

Raphael Bauer: Fractional and Interim CTO with 20+ years of experience building and transforming engineering organizations in high-pressure environments: PE transitions, hypergrowth, and new product launches. My strengths include delivery predictability, org design, executive communication, and hiring and coaching senior leaders.

...

You need simplicity if you want to move fast. PostgreSQL can very well replace Kafka, Redis, Clickhouse and Elastic. And many successful companies are doing exactly that. Let's check out how

...

Contrary to popular belief - the answer to everything is NOT 42 - it’s PostgreSQL. (ok. It might also be Postgres).

This article was discussed on Hacker News. The thread contains a lot of additional links, real-world experience and critical thoughts - well worth reading.

Table Of Contents

  • Intro
  • Rock Solid and Stable
  • Easy to Run, Install and Scale
  • Simplifies Your IT Setup
    • PostgreSQL Replaces Solr and Elastic: Full-Text Search
    • PostgreSQL replaces MongoDB: Excellent Json Support
    • PostgreSQL replaces Kafka and RabbitMQ: PostgreSQL as a queue
    • PostgreSQL Replaces Clickhouse: High Volume Time Series Data
    • PostgreSQL as Vector Database for AI Workflows
    • PostgreSQL Replaces Redis: Non-Persistent High Performance Caching
    • PostgreSQL Replaces File System: For Raw Data
    • PostgreSQL Replacing Your Graph Database
    • PostgreSQL as Replacement for GraphDB and Neo4J
    • PostgreSQL Replacing Your Microservice
    • PostgreSQL - Replacing your Playstation 5
  • Conclusion

Intro

I started using PostgreSQL roughly in 2003 for a research project called ColumbaDB. Columba is no more, but PostgreSQL is still alive and kicking more than ever.

In 2003, MySQL was much more widely used than PostgreSQL. MySQL was also potentially faster as it did not implement all features of the SQL standard. At the same time MySQL was lacking many features that we needed (full-text search, powerful indexes, SQL standard compliance etc). PostgreSQL felt more like a “real” database in comparison to MySQL - like a tiny version of Oracle - but in open source clothes.

During that research project I learned a lot about databases, indexes and the power of PostgreSQL. One important use-case was full-text search. We could have used MySQL in conjunction with another system like Lucene / Solr to make our database searchable. But that would have meant running and maintaining two such systems. Complicated.

PostgreSQL allowed us to use a fulltext search plugin to do everything in one system. No need to sync any data. No need to maintain and run two systems. It just worked and made us smile (after some tweaks of course). Simplicity.

Since then I used PostgreSQL for many use-cases throughout my career as CTO / Interim Manager. Most recently I used PostgreSQL to store very high volume web analytics time series data via its TimescaleDB plugin. Check out Privatracker - the best way to do web analytics and respect the privacy of your visitors - to see it in action.

Many others discussed the topic from different angles. And each article is really worth your time (SQL is Agile, Stephan Schmidt on Using SQL for Everything). Also check out my Linkedin post.

And if you are using PostgreSQL I can highly recommend reading Hazel Bachrach’s nice post on “What I Wish Someone Told Me About Postgres”.

In my humble opinion the power of PostgreSQL comes from three sources:

  1. It is rock-solid and stable.
  2. It is easy to run, install and scale.
  3. It massively simplifies your IT setup by being not only a RDBMS, but also a full-text search engine, a document storage and much much more…

Let’s have a closer look…

Rock Solid and Stable

PostgreSQL is boring old technology. The first PostgreSQL release dates back to 1996. PostgreSQL is also very widely used - for a very long amount of time. Ironing out bugs - especially in database systems - takes time. PostgreSQL had that time.

It also has a very active community that diligently adds more and more features without breaking any old parts of it. In recent years PostgreSQL got many amazing features like json document storage, partitioning support, common table expressions and much much more. Each new release of PostgreSQL is exciting and brings new nice features.

True - PostgreSQL is old - but the features are very very modern - and PostgreSQL becomes better with every release.

Easy to Run, Install and Scale

PostgreSQL can be installed very easily locally. It is bundled with all major Linux distributions, part of Mac brew, but can also be installed with applications like PostgresApp.

When running tests, it comes in handy using Testcontainers with PostgreSQL. It was never easier running your tests against a real PostgreSQL database that is 100% similar to the production thing.

If you want to run PostgreSQL on a server then you can simply apt-get install it. Or run it in a docker container.

All cloud providers allow you to run (and scale!) PostgreSQL by clicking a single button. You got ample of choice at your fingertips:

  • Amazon AWS
  • Google GCP
  • Microsoft Azure
  • ElephantSQL
  • CrunchyData
  • Timescale

… and many more …

That makes PostgreSQL one of the most widely supported software systems in the market. And for you this means less maintenance and more time for creating new features for clients.

Simplifies Your IT Setup

Running PostgreSQL in the cloud is already just one click. But it gets even better. PostgreSQL can replace many systems that youd’d have to run otherwise.

PostgreSQL Replaces Solr and Elastic: Full-Text Search

PostgreSQL allows you to turn your text data into user-searchable data. Without a separate system. It’s also language agnostic and you’ll never have any sync problems between your data and your fulltext search system.

The most impressive article on the topic is how Contentful used PostgreSQL to enable fulltext search for their users. It’s a tale in simplicity that enables growth.

Instacart did something very similar: They built their modern search infrastructure on Postgres instead of running a separate search cluster. Same story, different company.

The built-in tsvector / tsquery machinery is a very good starting point. It is part of vanilla PostgreSQL, needs no extra moving parts, and works extremely well for the vast majority of use-cases. Start there.

If you outgrow it - typically because you need better relevance ranking (BM25) or more scalability - you don’t have to leave PostgreSQL either. There are extensions that pick up exactly where the built-in search ends:

  • pg_textsearch by Timescale / TigerData - BM25 ranking as a PostgreSQL extension.
  • ParadeDB / pg_search - Elasticsearch-grade search inside PostgreSQL, built on Tantivy (docs).

That’s the beauty of it: You can start with plain vanilla PostgreSQL and seamlessly move to more advanced techniques and third-party extensions once - and only if - you actually need them.

The pros and cons of full-text search on PostgreSQL are discussed very nicely in this LinkedIn discussion - recommended reading before you decide.

More on the topic: https://www.postgresql.org/docs/current/textsearch.html

PostgreSQL replaces MongoDB: Excellent Json Support

PostgreSQL has excellent support for storing and querying(!) json. It also features an index type (GIN) that makes these operations blazingly fast. Is there a need for MongoDB any more?.

The Guardian also wrote an excellent article how they switched from Mongo to PostgreSQL. Thanks for sharing Jan-Otto! Hazel also wrote a nice piece on jsonb and what to take into account when using it.

PostgreSQL replaces Kafka and RabbitMQ: PostgreSQL as a queue

Events, queues and persistent logs are getting more and more important in today’s software systems. Systems like Kafka, RabbitMQ, SQS and others provide that functionality. But maintaining them is annoying, custom and you need the skillset.

The good news: You can just use PostgreSQL. The magic comes from

SELECT .. FOR UPDATE
SELECT .. SKIP LOCKED

Using these SQL features you can effectively use a table as queue. Either in a persistent fashion with a cursor and many consumers, or in a read-once fashion.

The article at crunchydata explains this concept very well.

My tip: Start with PostgreSQL as a queueing system. Only when that does no longer perform well switch to other systems like Kafka, RabbitMQ or SQS. You’ll be surprised how well PostgreSQL works.

PostgreSQL Replaces Clickhouse: High Volume Time Series Data

Time series data is special. Often you get many data points in a very short amount of time. And then you have to aggregate the data frequently, doing some statistics on it and so on.

There are specialized software systems like Clickhouse (amazing by the way…). But you can also use a plugin for PostgreSQL that allows you to do (nearly) the same: Timescale.

I’ve used Timescale and can recommend it. The good news is that you can continue using PostgreSQL - even for high volume data easily. No need to learn and maintain something new.

PostgreSQL as Vector Database for AI Workflows

Timescale lately released the pgvector extension, that turns your PostgreSQL into a vector database. This allows you to use the tech you already know for indexing and retrieval of relevant data. That’s an essential part of AI LLM workflows.

Timescale also recently announced pgai that includes pgvector, but also a lot of other nice extensions that make it super simple to index data, call LLM models and retrieve data based on similarity.

PostgreSQL Replaces Redis: Non-Persistent High Performance Caching

Caching is important. Most applications use something like Redis as a cache to get information like sessions and more quickly. A cache can by definition lose data and can be regenerated from the original source.

But. Why use Redis when PostgreSQL can be tuned to be as fast (in most usecases) as a Redis cache? The secret is using an UNLOGGED table. You can even emulate Redis’ automatic expire by a trigger. A lot has been written about this - I can just recommend trying it out.

PostgreSQL Replaces File System: For Raw Data

For one of my clients we had to read and write a huge amount of small pieces of binary encoded information. We initially thought that doing this via the file system was the fastest way to do so.

After some performance checks it became clear that PostgreSQL was even faster than reading from the file system for our use-case. PostgreSQL uses the file system very efficiently for its data - and it adds a lot of caching and efficient reading and writing strategies that can outperform writing and reading raw data on a file system.

We used Flatbuffers to store the data in a blob column. Data was then de-serialized on the client. You might want to try that approach as well.

PostgreSQL Replacing Your Graph Database

Hierarchical data can be managed in SQL via recursive queries. That’s ok, but also super-hard to read, maintain and debug. Not even speaking of performance.

The better way is the LTREE datatype of PostgreSQL. It helped me not only once to implement hierarchical tag structures. Easy to read, maintain and blazingly fast.

PostgreSQL as Replacement for GraphDB and Neo4J

But what if you need a real graph - not just a tree? Nodes, edges, properties, and queries that traverse relationships in every direction? That’s usually the moment someone suggests adding Neo4j to the stack. And with it: another system to run, another backup strategy, another data sync.

You don’t have to. Apache AGE (“A Graph Extension”) turns PostgreSQL into a graph database. It’s an Apache Software Foundation top-level project and it implements openCypher - the same query language you’d use in Neo4j. The best part: graph queries and plain SQL live in the same database and can be combined in a single statement.

SELECT * FROM cypher('my_graph', $$
    MATCH (a:Person)-[:WORKS_AT]->(c:Company)
    RETURN a.name, c.name
$$) AS (person agtype, company agtype);

My tip - the same one as for queueing: Start with PostgreSQL. Model your graph with AGE (or with LTREE if a hierarchy is all you need) and only reach for a dedicated graph database if you really hit the limits. Your data stays in one place, transactional and consistent with the rest of your application.

More on the topic: Apache AGE documentation and the source on GitHub.

PostgreSQL Replacing Your Microservice

Most of the “microservices” these days are only about models, getting data from a database and returning json to the client.

But you know what? PostgreSQL can turn any query into a Json result. That effectively replaces your server middleware. There are Pros and Cons to this approach, but it shows the capabilities of PostgreSQL. The amazing Lukas Eder wrote about the topic - not PostgreSQL specific - but everything mentioned there is very well doable in PostgreSQL as well

PostgreSQL - Replacing your Playstation 5

Well. Some enthusiast implemented Tetris as Common Table Expressions in pure SQL. Crazy. And maybe not to be taken too seriously.

Conclusion

The list above is not very exhaustive. PostgreSQL is a very flexible piece of software. And it can be extended with plugins to do more and more.

You need simplicity if you want to move fast. If you come across a new requirement always ask: Can’t PostgreSQL do this? And do we really need that shiny new technology X?

PostgreSQL might not be the answer to everything - but it is the answer to a lot more than you might think!

Add a data source using Coldfusion Administrator API

Mike's Notes

Google Search - AI Mode (Gemini) was used to find the sample code for adding a data source using the ColdFusion Administrator API.

Speed is king

My strength is architecture and problem-solving, not coding (a slow writer and too many typos), so 99.9% of the coding is now supplied via Google Search - AI Mode (Gemini), then I test everything. It's about 100X faster. 😎😎😎😎😎😎😎

Data sources

data/

  • couchbase/
  • db2/
  • derby/
  • h2/
  • hsqldb/
  • mariadb/
  • msaccess/
    • 32/ (32-bit)
    • 64/ (64-bit)
  • mssql/
  • oracle/
  • pg/
    • 18/ (version 18)
  • sqllite/
  • sybase/
  • virtuoso/

Other issues to explore later

  • The advanced arguments (such as connection pool limits or timeout settings) for these specific drivers.

Future testing

  • Install loki-01 on 9cc/
  • Run the Data Engine (dat) to create all data sources
  • Test data sources.

Here is a detailed example copied from the ColdFusion Cookbook. It is written by Jeremy Petersen and was last updated in 2007.


How do I programmatically create a new datasource?

The short answer is to use the ColdFusion Administrator API.


The following is taken directly from the ColdFusion documentation:
You can perform most ColdFusion MX Administrator tasks programmatically using the Administrator API. The Administrator API consists of a set of ColdFusion components (CFCs) that contain methods you call to perform Administrator tasks. For example, you use the setMSQL method of datasource.cfc to add a SQL Server data source.

<cfscript>
   // Login is always required. This example uses a single line of code.
   createObject("component","cfide.adminapi.administrator").login("admin");
   // Instantiate the data source object.
   myObj = createObject("component","cfide.adminapi.datasource");
   // Required arguments for a data source.
   stDSN = structNew();
   stDSN.driver = "MSSQLServer";
   stDSN.name="northwind_MSSQL";
   stDSN.host = "10.1.147.73";
   stDSN.port = "1433";
   stDSN.database = "northwind";
   stDSN.username = "sa";
   // Optional and advanced arguments.
   stDSN.login_timeout = "29";
   stDSN.timeout = "23";
   stDSN.interval = 6;
   stDSN.buffer = "64000";
   stDSN.blob_buffer = "64000";
   stDSN.setStringParameterAsUnicode = "false";
   stDSN.description = "Northwind SQL Server";
   stDSN.pooling = true;
   stDSN.maxpooledstatements = 999;
   stDSN.enableMaxConnections = "true";
   stDSN.maxConnections = "299";
   stDSN.enable_clob = true;
   stDSN.enable_blob = true;
   stDSN.disable = false;
   stDSN.storedProc = true;
   stDSN.alter = false;
   stDSN.grant = true;
   stDSN.select = true;
   stDSN.update = true;
   stDSN.create = true;
   stDSN.delete = true;
   stDSN.drop = false;
   stDSN.revoke = false;
   //Create a DSN.
   myObj.setMSSQL(argumentCollection=stDSN);
</cfscript>

Resources

References

  • Reference

Repository

  • Home > Ajabbi Research > Library >
  • Home > Handbook > 

Last Updated

18/04/2026

Add a data source using Coldfusion Administrator API

By: Mike Peters and Gemini
On a Sandy Beach: 18/04/2026

Mike is the inventor and architect of Pipi and the founder of Ajabbi.

Google Gemini is cool.

The ColdFusion server stores data source configurations in specific XML files located within its installation directory. The exact location depends on your version and installation type (Server vs. J2EE configuration). [1, 2, 3, 4, 5] 

Configuration File Locations

Most modern Adobe ColdFusion versions (including ColdFusion 2021 and 2025) store these settings in the following files:

  • Main Datasource File: neo-datasource.xml.
  • Legacy/Alternative File: neo-query.xml (often used in older versions like ColdFusion 7). [1, 2] 

Default File Paths

The absolute path varies based on how ColdFusion was installed: [6] 

  • Server Configuration (Standard): {cf_root}/cfusion/lib/ (e.g., C:\ColdFusion2021\cfusion\lib\neo-datasource.xml on Windows).
  • JEE / Multiserver Configuration: {cf_webapp_root}/WEB-INF/cfusion/lib/.
  • Central Config Server (CCS): If using a [Central Config Server](https://helpx.adobe.com/coldfusion/using/central-config-server-coldfusion.html), settings are stored in <CF_Instance_Home>/bin/ccsstore/ and replicated across nodes. [1, 2, 3, 7] 

Important Notes

  • Encryption: While you can see the DSN names and server addresses in these XML files, passwords are encrypted and cannot be read in plain text.
  • Editing: It is highly recommended to manage datasources through the ColdFusion Administrator (found under Data & Services > Data Sources) rather than editing the XML files directly to avoid corruption.
  • API Access: You can also manage these settings programmatically using the [ColdFusion Administrator API](https://helpx.adobe.com/coldfusion/kb/database-connections-handled-coldfusion.html). [1, 8, 9, 10, 11, 12] 

References

To use the ColdFusion Administrator API to add a datasource, you use a set of ColdFusion components (CFCs) located in the /CFIDE/adminapi/ directory. This allows you to manage server settings programmatically without using the web-based Administrator interface. [1, 2, 3, 4] 

Prerequisites

  • Enable RDS: Some versions or configurations may require enabling the RDS service in Security > RDS within the ColdFusion Administrator.
  • Administrator Password: You must have the server's administrator password to authenticate. [1, 5, 6, 7] 

Basic Steps and Example Code

The following cfscript example demonstrates how to log in and create a Microsoft SQL Server datasource using the datasource.cfc (or Data Sources.cfc in some versions). [5, 8] 

<cfscript>
    // 1. Authenticate with the Administrator API
    adminObj = createObject("component", "cfide.adminapi.administrator");
    adminObj.login("yourAdminPassword"); // Replace with your actual admin password
    // 2. Instantiate the Data Source object
    // Note: Use "datasource" or "Data Sources" depending on your CF version
    dsObj = createObject("component", "cfide.adminapi.datasource");
    // 3. Define the datasource details
    // For SQL Server, you use the setMSSQL method
    dsObj.setMSSQL(
        name = "MyNewDSN",
        host = "127.0.0.1",
        port = "1433",
        database = "MyDatabaseName",
        username = "dbUser",
        password = "dbPassword",
        description = "Datasource created via Admin API"
    );
    // 4. (Optional) Verify the connection
    isVerified = dsObj.verifyDsn("MyNewDSN");
    
    if (isVerified) {
        writeOutput("Datasource successfully created and verified!");
    } else {
        writeOutput("Datasource created but failed verification.");
    }
</cfscript>

Common Methods for Different Drivers

The datasource component provides specific methods for different database types: [4, 9, 10] 

  • SQL Server: setMSSQL()
  • MySQL: setMySQL5() or setMySQL()
  • [Oracle](https://www.oracle.com/): setOracle()
  • Derby: setDerbyEmbedded()
  • Other JDBC: setOther() (used for custom or thin drivers) [4, 5, 6, 8, 11, 12, 13] 

Alternative: Per-Application Datasources [14] 

In ColdFusion 2021 and later, you can also define datasources directly in your Application.cfc without using the Admin API. This is often preferred for modern application deployment as it keeps configurations within the application code. [14, 15, 16, 17, 18]

// In Application.cfc
component {
    this.name = "MyApplication";
    this.datasources["myDSN"] = {
        driver: "MSSQLServer",
        host: "localhost",
        database: "mydb",
        username: "user",
        password: "pass"
    };
    this.datasource = "myDSN";
}

References

To use the ColdFusion Administrator API with tag-based syntax, you must first authenticate using administrator.cfc, then call the specific driver methods in datasource.cfc.

1. Authentication (Required First)

Before adding any datasource, you must log in to the API session. [1] 

<cfset adminObj = createObject("component", "cfide.adminapi.administrator")>
<cfset adminObj.login("yourAdminPassword")>
<!--- Instantiate the datasource object for subsequent calls --->
<cfset dsObj = createObject("component", "cfide.adminapi.datasource")>

------------------------------

2. Database-Specific Tag Examples

Microsoft SQL Server

Uses the setMSSQL method.

<cfset dsObj.setMSSQL(
    name = "MyMSSQL_DSN",
    host = "127.0.0.1",
    database = "Northwind",
    username = "db_user",
    password = "db_password"
)>

PostgreSQL

Uses the setPostgreSQL method.

<cfset dsObj.setPostgreSQL(
    name = "MyPostgres_DSN",
    host = "localhost",
    database = "sales_db",
    username = "postgres",
    password = "password"
)>

MySQL

Uses setMySQL5 (for MySQL 5.x and later, including MariaDB).

<cfset dsObj.setMySQL5(
    name = "MyMySQL_DSN",
    host = "://example.com",
    database = "inventory",
    username = "admin",
    password = "secretpassword"
)>

Oracle

Uses setOracle. Note that Standard edition users often use the "Other" method with an Oracle thin driver.

<cfset dsObj.setOracle(
    name = "MyOracle_DSN",
    host = "oracle_server",
    service_name = "ORCL",
    username = "system",
    password = "password"
)>

MS Access (32-bit vs. 64-bit)

Both use the setMSAccess method, but the behaviour depends on the server's ODBC configuration and the ColdFusion bitness.

  • 32-bit: Requires the 32-bit Microsoft Access Database Engine.
  • 64-bit: Requires the 64-bit Microsoft Access Database Engine.

<cfset dsObj.setMSAccess(
    name = "MyAccessDSN",
    database = "C:\data\mydb.mdb"
)>

Sybase

Uses the setSybase method.

<cfset dsObj.setSybase(
    name = "MySybase_DSN",
    host = "sybase_host",
    database = "production",
    username = "sa",
    password = "password"
)>

DB2

Uses the setDB2 method.

<cfset dsObj.setDB2(
    name = "MyDB2_DSN",
    host = "db2_server",
    database = "SAMPLE",
    username = "db2admin",
    password = "password"
)>

------------------------------

3. Verification (Recommended)

After adding a datasource, it is best practice to verify the connection immediately.

<cfset isVerified = dsObj.verifyDsn("MyMSSQL_DSN")>
<cfif isVerified>
    <p>Connection Successful!</p>
<cfelse>
    <p>Connection Failed. Check credentials or server availability.</p>
</cfif>

References