Skip to main content

Say Hello to PostgreSQL: Bucketeer Now Runs on MySQL or Postgres

· 6 min read
Hien Vu Ngoc
Software Engineer

Bucketeer's main application — the console and every piece of core data behind it — has always run on MySQL. Starting with this release, you can run it on PostgreSQL instead. Pick your database with a single configuration value; everything else works exactly the same.

Why PostgreSQL?

More choice, less friction. PostgreSQL is one of the most widely deployed databases in the world, and a lot of organizations already run it across their infrastructure. For teams standardized on Postgres, using it for Bucketeer as well means one less database engine to run and maintain alongside their existing stack. If Postgres is already your database of choice, now you can use it.

It's a natural next step. Earlier this year we added PostgreSQL as a data warehouse option for events and analytics. Extending Postgres support to the operational database was the logical follow-through: with both in place, you can now run Bucketeer end-to-end on Postgres if you want a single engine across the whole stack.

The DWH story is even better on Postgres. PostgreSQL has the TimescaleDB extension — hypertables, time-based partitioning, and compression that fit the OLAP-style event and time-series workloads a data warehouse handles. Standardizing on Postgres lets you lean on that for analytics while using the same engine for your operational data.

The challenge

Adding a second database sounds simple until you look at the details. Two problems made it hard.

MySQL details had leaked into places they didn't belong. The core problem was that the API layer carried too many MySQL dependencies. Services held MySQL clients directly, and — more troublesome — the API layer assembled its own query pieces: list filters, ordering, and pagination were built using MySQL's query-builder types before ever reaching storage. In some cases a "column" in a filter wasn't a column name at all, but a raw MySQL SQL expression:

// In the API layer — MySQL query-building where it doesn't belong.
filters = append(filters, &mysql.FilterV2{
Column: "JSON_CONTAINS(JSON_EXTRACT(rules, '$[*].clauses[*].operator'), '11')",
Operator: mysql.OperatorEqual,
Value: true,
})

That Column is a full MySQL JSON expression, used to find flags with a certain kind of rule — PostgreSQL would need an entirely different predicate. MySQL concerns like JSON handling, duplicate-key errors, and placeholder style leaked all the way up. Every one of those spots pinned the code to MySQL, so simply pointing it at PostgreSQL was never going to work.

The two engines differ in more than surface syntax. Even where the code was clean, MySQL and PostgreSQL don't speak quite the same SQL, and the gaps aren't things you can paper over with a search-and-replace:

  • Placeholders — MySQL uses ?, PostgreSQL uses numbered $1, $2, …
  • JSON — MySQL has JSON_CONTAINS / JSON_EXTRACT; PostgreSQL uses JSONB operators like @> and jsonb_path_exists.
  • Upserts — MySQL's ON DUPLICATE KEY UPDATE versus PostgreSQL's ON CONFLICT (...) DO UPDATE.
  • Generated IDs — MySQL's AUTO_INCREMENT versus PostgreSQL's SERIAL / IDENTITY, which also changes how you read back a newly inserted ID.

A naive approach — one shared code path that just rewrites each ? into a numbered $1, $2, … at the last moment — falls apart on all of the above. It isn't SQL-aware (a ? inside a string literal breaks it), and it does nothing for JSON, upserts, or aggregates that are structurally different between the two. You'd end up with a tangle of "if it's Postgres, do this" branches scattered through the codebase.

Our solution

We introduced a polymorphic storage layer — the database is chosen at runtime by a single configuration value, and the rest of the codebase stays engine-agnostic. At a high level:

Common concerns are abstracted, engine-specific SQL is kept explicit. Shared behavior like transactions sits behind one small interface, so services no longer depend on a MySQL client:

// A narrow interface — no MySQL or PostgreSQL types in sight.
type Client interface {
RunInTransactionV2(ctx context.Context, f func(ctx context.Context) error) error
Close() error
}

The parts that genuinely differ live side by side in their own packages — clear and easy to review, rather than hidden behind scattered conditionals.

The rest of the code speaks in intent, not SQL. Instead of building query fragments, handlers describe what they want with plain parameters, and each database implementation decides how to express it:

// The API asks for this — not a MySQL filter, not raw SQL.
type ListFeaturesParams struct {
EnvironmentID string
Tags []string
HasFeatureFlagAsRule *bool
PageSize int64
Cursor string
// ...
}

The MySQL implementation turns that into JSON_CONTAINS(...); the PostgreSQL one turns the same request into a JSONB predicate. Only one place — where the server starts up — knows which engine is actually in use, choosing the implementation from a single config value:

switch cfg.StorageType {
case "mysql":
dbClient = database.NewMySQLStorageClient(mysqlClient)
featureStorage = v2fs.NewMySQLFeatureStorage(mysqlClient)
case "postgres":
dbClient = database.NewPostgresStorageClient(pgClient)
featureStorage = v2fs.NewPostgresFeatureStorage(pgClient)
}

We could build it piece by piece. This structure is what made the work practical to ship. Because the engine is picked per service at startup, and every service keeps its own storage implementation behind a common interface, PostgreSQL support could land one service at a time rather than as a single high-risk rewrite. We started with feature flags as the reference pattern — refactoring its API to speak in semantic parameters, then adding the PostgreSQL storage alongside the existing MySQL one — and repeated the same recipe service by service. Throughout, MySQL stayed the default and kept working untouched, so each increment was safe to merge on its own, and any service without a PostgreSQL implementation yet simply continued on MySQL.

We applied this pattern across every service — feature flags, experiments, event counters, notifications, Auto Ops, scheduled changes, insights, code references, teams, accounts, and more — so Bucketeer on PostgreSQL is now functionally equivalent to Bucketeer on MySQL. Nothing is left behind.

What this means for you

  • MySQL is still the default and remains fully supported. Existing deployments don't change.
  • PostgreSQL is a first-class alternative — same console, same features, selected by configuration.
  • Same deployment options — Docker Compose and Kubernetes/Helm — for both Bucketeer Lite and self-hosted setups.
  • End-to-end Postgres is now possible: operational database and data warehouse on the same engine, with TimescaleDB available for analytics.

Get started

PostgreSQL as an operational database is available now. Check the Bucketeer repository for Docker Compose and Kubernetes/Helm configuration and the database setup steps, then point the main application at PostgreSQL through the StorageType configuration.

We'd love to hear how it works for you. Give it a try and let us know your feedback!