Skip to content

Databases

bddkit supports PostgreSQL, MySQL 8 and MariaDB from MariaDB 10.5, the release that adds RETURNING, which bddkit uses to read a generated key back. Nothing refuses an older MariaDB at startup; the first insert into a table with a primary key fails with a syntax error from the server.

The engine comes from the DSN scheme: postgres:// or mysql://. MySQL and MariaDB share that scheme, so bddkit tells them apart with one SELECT VERSION() when the connection pool is built. Every DB step is spelled the same on all three engines.

  • Sequences. MySQL has none, so I get next value of sequence fails naming the engine. Postgres and MariaDB both have them.
  • search_path. On MySQL and MariaDB a schema is a database, so there is no session-level search path. The key is refused at startup rather than ignored: name the database in the DSN, or qualify a table as database.table in the step.
  • binary and varbinary columns, including binary(16) UUIDs. bddkit compares everything as text, and the byte order of UUID_TO_BIN is not recorded anywhere it could read, so it refuses the column rather than guess. Store the UUID as char(36), and bddkit fills it client-side.
  • A primary key filled by a server-side DEFAULT that is not AUTO_INCREMENT, on MySQL. There is no RETURNING to read the value back with, so the step fails naming the column before it inserts anything. Give the value explicitly.
  • Read-back values are the engine’s own text. now() reads back as 2026-08-29 15:11:50.884052+00 on Postgres and 2026-08-29 15:11:50 on MySQL and MariaDB; a boolean reads as true or 1. So I extract followed by a comparison is portable only over text-like columns. A where on the same columns is unaffected, because the engine converts the value back to its own type.

In debug mode each DB step prints the query’s duration after its SQL and parameters, including for a failing query. Under concurrency greater than 1 the number includes the wait for a pooled connection, so run with concurrency: 1 for an accurate measurement.

Every <<unique()>> value is u followed by the run’s own prefix and a counter, and <<run_id>> is that prefix. A ~ after the column name makes the comparison a LIKE instead of =, so one step removes exactly the rows this run wrote:

@serial(demo) @priority(-100)
Feature: cleaning up what this run created
Scenario: remove the companies this run created
When I delete "companies" where "slug~: u<<run_id>>%"

Things to know about it:

  • The operator lives on the column, never in the value. slug: 20% is exact equality against the text 20%; only slug~: is a pattern. A % that arrives through a variable, from an API response for instance, cannot widen a DELETE.
  • Four operators work in every step that takes a condition. col: is =, col!: is <>, col~: is LIKE, col!~: is NOT LIKE. <<null>> gives IS NULL under : and IS NOT NULL under !:. Under ~ the value is an SQL LIKE pattern in full: _ matches any single character, and \% and \_ are the literal characters.
  • A negation with a value never matches a NULL column. name!: keeper is SQL’s name <> 'keeper', which is unknown for a row whose name is NULL. “Everything that is not keeper” therefore leaves those rows behind. Add name: <<null>> as a second condition to remove them too.
  • <<run_id>> is a reserved name. Like <<null>>, <<uuid()>> and <<unique()>>, it is answered before your own variables are read, so a variable named run_id is shadowed.
  • Only <<unique()>> carries the run prefix. <<unique(number)>> is microseconds plus a counter, with no prefix, so a LIKE on <<run_id>> never reaches a column filled that way. Delete those rows by a column that carries a token, or by their key.
  • @priority(-100) makes the file last in its own chain, not in the run. Chains run in parallel, so the cleanup file and the files whose data it removes belong in one @serial(<name>) chain. Otherwise the cleanup can delete rows another chain is still using.

The per-engine differences are also in examples/README.md in the bddkit repository.