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.
What does not carry over
Section titled “What does not carry over”- Sequences. MySQL has none, so
I get next value of sequencefails 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 asdatabase.tablein the step.binaryandvarbinarycolumns, includingbinary(16)UUIDs. bddkit compares everything as text, and the byte order ofUUID_TO_BINis not recorded anywhere it could read, so it refuses the column rather than guess. Store the UUID aschar(36), and bddkit fills it client-side.- A primary key filled by a server-side
DEFAULTthat is notAUTO_INCREMENT, on MySQL. There is noRETURNINGto 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 as2026-08-29 15:11:50.884052+00on Postgres and2026-08-29 15:11:50on MySQL and MariaDB; a boolean reads astrueor1. SoI extractfollowed by a comparison is portable only over text-like columns. Awhereon the same columns is unaffected, because the engine converts the value back to its own type.
Debug output
Section titled “Debug output”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.
Cleaning up what a run created
Section titled “Cleaning up what a run created”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 text20%; onlyslug~:is a pattern. A%that arrives through a variable, from an API response for instance, cannot widen aDELETE. - Four operators work in every step that takes a condition.
col:is=,col!:is<>,col~:isLIKE,col!~:isNOT LIKE.<<null>>givesIS NULLunder:andIS NOT NULLunder!:. Under~the value is an SQLLIKEpattern in full:_matches any single character, and\%and\_are the literal characters. - A negation with a value never matches a NULL column.
name!: keeperis SQL’sname <> 'keeper', which is unknown for a row whosenameis NULL. “Everything that is not keeper” therefore leaves those rows behind. Addname: <<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 namedrun_idis shadowed.- Only
<<unique()>>carries the run prefix.<<unique(number)>>is microseconds plus a counter, with no prefix, so aLIKEon<<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.