Optimizing #[sqlx::test] rebuild time | Kobzol’s blog
You might find this post especially useful if you have a project with many #[sqlx::test] tests.
One of the upstream Rust projects that I worked on during the past few years was the rewrite of bors,<br>the merge queue bot we use to merge all rust-lang/rust PRs. If you are interested in learning more about this bot, check out my talk from RustWeek 2026.
I’m quite proud of the integration test suite of bors, which I spent a lot of effort on, and thanks<br>to which the bot has been working pretty much flawlessly since we deployed it to production in January 2026<br>(despite GitHub lately often having… troubles).
One thing that I’m not very happy about though is the incremental rebuild time of bors, and in particular its<br>test suite. It takes a long time (~8-10s) to rebuild the tests after each change on my laptop,<br>which is quite bad for productivity.
Recently I finally found some time to profile1 its build time, and learned that it is caused by a<br>combination of several factors:
Generation of debug information takes a long time. This is a known issue, but in<br>this case I didn’t want to give up debug info, because I actually debug and step through bors tests<br>quite often.
rustc takes a long time to load and persist the incremental session. I plan to take a look into this.
Probably because of all the debuginfo (the final binary has like 220 MiB), it takes lld a whole second (!)<br>to link the tests. With wild, it’s just ~200ms.
The sqlx::tests that I am using heavily in bors take a long time to compile. This is what I will focus on<br>in this post.
Slow compilation of sqlx tests
As a frame of reference, for my benchmark I was using touch && time cargo test --no-run.<br>Even after a no-op change, it took ~7.5 seconds to recompile the tests, which is super slow.
Of course, it is very well known that sqlx’s proc macros can slow down compilation times,<br>because of all the crimes interesting things that they do2. However, the case that I encountered<br>might not be so obvious. In my case, sqlx did not actually even connect to a database! Because I’m compiling with SQLX_OFFLINE=1, unless I work directly on SQL queries. And yes, I am setting<br>opt-level = 3 for the sqlx-macros crate, as recommended by the sqlx documentation.
So what is happening here? To find out, it is important to understand what is happening when you<br>have a test like this:
#[sqlx::test]<br>async fn test_foo(pool: sqlx::PgPool) {}
The #[sqlx::test] attribute is super useful, because it creates a new database before the execution<br>of the test, runs migrations on it, and then gives you a database connection pool, so that you can<br>run your tests against an actual database, and not against a mocked HashMap3.
Wait, did I say migrations? Hmm, where does it find them? Well, from disk, of course! Each usage of<br>#[sqlx::test] will gather all migrations from a directory on disk, and then read, parse, validate<br>and hash each migration. Perhaps counter-intuitively, this part is not that slow! Turns<br>out that Rust is actually quite fast (who knew, right??), and if you do not have gigabytes of migrations, I/O is probably also not a problem4.
What is worse is the generated output of those macros. For each such test, the macro will generate a<br>complete list of migrations, including their text content and a checksum in the form of a byte array,<br>in the Rust source code as a constant. So if you expand the macro, before each test you’ll find something<br>like this:
args.migrator(&::sqlx::migrate::Migrator {<br>migrations: ::std::borrow::Cow::Borrowed(&[<br>::sqlx::migrate::Migration {<br>version: 20240517094752i64,<br>description: ::std::borrow::Cow::Borrowed("create build"),<br>migration_type: ::sqlx::migrate::MigrationType::ReversibleUp,<br>sql: ::std::borrow::Cow::Borrowed("CREATE TABLE )"),<br>no_tx: false,<br>checksum: ::std::borrow::Cow::Borrowed(&[193u8, 202u8, skipped>]),<br>},<br>::sqlx::migrate::Migration {<br>skipped><br>},<br>skipped><br>]),<br>skipped><br>});
The example above is shortened, and it skips a lot of stuff. The actual generated code will be much longer, and of course it scales with the number (and content) of your migrations.
Now, if you have something like this in your source code once, that’s not so bad. However, in bors, there are ~350 sqlx tests and 30 migrations. And at that point, it starts to add up rather quickly.
To test my hypothesis that migrations might be causing some of the build slowness, I tested what would happen<br>if I had only one migration by deleting the rest of them. And sure enough, the rebuild time immediately went from ~7.5s to ~5s! What is perhaps even more telling is that the size of the output of cargo expand --lib --tests went from 32 MiB (!) with 30 migrations to “only” 6 MiB with a single migration. Compiling an additional 26 MiB of Rust code sure isn’t for free.
It wasn’t just about the compilation time of the generated code though. In the profiles, it looked like converting all the migration description data to tokens using...