Generating short codes and handling collisions
Step 9 of 15. Every link created so far carries the same code. Step 4 put the 'stub' literal into
the INSERT as a marker, and step 8 turned that marker into a real liability: post two links, and
both rows answer to GET /stub, which redirects to whichever Postgres happens to return first.
This step replaces the stub with a generated code and makes Postgres the referee: a unique index on
code, a base62 encoder, a draw from a 3.5-trillion-code space, and a retry for the rare draw that
lands on an existing row.
Start from step 8’s end state: git checkout api-ch08 in your clone of
the examples repository, inside axum-sqlx-api/,
with the Postgres container from step 5 running and DATABASE_URL exported. Nothing is added to
Cargo.toml this step — the generator is twelve lines of standard library — and the file below is
the one the service has carried since step 5, shown because every block in this article compiles
against exactly it:
[dependencies]axum = { version = "0.8", features = ["macros"] }serde = { version = "1", features = ["derive"] }sqlx = { version = "0.9", features = ["runtime-tokio", "tls-rustls", "postgres", "macros"] }tokio = { version = "1", features = ["macros", "rt-multi-thread"] }The encoder and its tests
Section titled “The encoder and its tests”Above the handler structs in src/main.rs goes the section the whole step is built on:
const ALPHABET: &[u8; 62] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
// 62^7 is about 3.5 trillion codes. A full u64 would need up to 11// characters; seven is plenty and keeps every code the same length.const CODE_SPACE: u64 = 62u64.pow(7);
fn to_base62(mut n: u64) -> String { let mut digits = [0u8; 11]; let mut len = 0; loop { digits[len] = ALPHABET[(n % 62) as usize]; len += 1; n /= 62; if n == 0 { break; } } digits[..len].iter().rev().map(|&b| b as char).collect()}
fn next_code(state: &mut u64) -> String { // xorshift64*: three shifts and a multiply turn the previous number into // the next one. 0 is a fixed point, so the seed must never be 0 - the // clock in create_link cannot produce 0, and neither can this sequence. *state ^= *state >> 12; *state ^= *state << 25; *state ^= *state >> 27; let n = state.wrapping_mul(0x2545_F491_4F6C_DD1D); // A small n encodes to fewer than seven digits. '0' is base62's zero, // so left-padding does not change the value, and every code comes out // the same length. format!("{:0>7}", to_base62(n % CODE_SPACE))}
#[cfg(test)]mod tests { use super::*;
#[test] fn encodes_the_boundaries() { assert_eq!(to_base62(0), "0"); assert_eq!(to_base62(61), "z"); assert_eq!(to_base62(62), "10"); assert_eq!(to_base62(CODE_SPACE - 1), "zzzzzzz"); }
#[test] fn codes_are_seven_characters_from_the_alphabet() { let mut state = 42; for _ in 0..1_000 { let code = next_code(&mut state); assert_eq!(code.len(), 7); assert!(code.bytes().all(|b| ALPHABET.contains(&b))); } }
#[test] fn the_sequence_keeps_moving() { let mut state = 42; let first = next_code(&mut state); let second = next_code(&mut state); assert_ne!(first, second); }}to_base62 is the entire encoder: divide by 62 until nothing is left, collecting digits
least-significant first, then reverse. The alphabet runs digits, uppercase, lowercase, so
to_base62(61) is "z" and to_base62(62) is "10" — the same rollover decimal does at 9 to 10.
CODE_SPACE is 62⁷, about 3.5 trillion codes; a full u64 would need up to eleven characters, and
seven keeps every code short without meaningfully raising the collision rate.
The three tests ride along in the same file, and they are the reason next_code takes its state as
a &mut u64 rather than hiding it inside itself: a function you can feed a fixed seed is a
function you can assert against. cargo test:
cargo testrunning 3 teststest tests::encodes_the_boundaries ... oktest tests::the_sequence_keeps_moving ... oktest tests::codes_are_seven_characters_from_the_alphabet ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sThe order of those lines varies between runs — the test harness runs them in parallel.
Drawing a code
Section titled “Drawing a code”next_code is a xorshift64*: three shifts and a multiply turn the previous u64 into the next
pseudorandom one, and the result is clamped into the code space and padded to seven characters.
Padding matters more than it looks: a small draw encodes to fewer than seven digits, and
{:0>7} pads on the left with '0' — which is base62’s zero, so the padding changes the length
without changing the value.
To watch it produce codes, drop the functions into a scratch cargo new and print from a fixed
seed:
const ALPHABET: &[u8; 62] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
const CODE_SPACE: u64 = 62u64.pow(7);
fn to_base62(mut n: u64) -> String { let mut digits = [0u8; 11]; let mut len = 0; loop { digits[len] = ALPHABET[(n % 62) as usize]; len += 1; n /= 62; if n == 0 { break; } } digits[..len].iter().rev().map(|&b| b as char).collect()}
fn next_code(state: &mut u64) -> String { *state ^= *state >> 12; *state ^= *state << 25; *state ^= *state >> 27; let n = state.wrapping_mul(0x2545_F491_4F6C_DD1D); format!("{:0>7}", to_base62(n % CODE_SPACE))}
fn main() { println!("62^7 = {CODE_SPACE}"); for n in [0u64, 61, 62, CODE_SPACE - 1] { println!("to_base62({n}) = {}", to_base62(n)); } let mut state = 42; for _ in 0..5 { println!("next code from seed 42: {}", next_code(&mut state)); }}62^7 = 3521614606208to_base62(0) = 0to_base62(61) = zto_base62(62) = 10to_base62(3521614606207) = zzzzzzznext code from seed 42: Kg5v4W8next code from seed 42: 8nhgmYUnext code from seed 42: 218FsgAnext code from seed 42: X2BzxD5next code from seed 42: QGmT8YIto_base62(3521614606207) — 62⁷ minus one — comes out as seven lowercase z’s, the largest value
inside the code space. The five lines after it are one fixed sequence: same seed, same codes, on
your machine and everywhere else. The server will not use a constant seed — it draws one from the
clock — so the codes your service hands out will not match the ones in this article. That is the
point.
The unique index
Section titled “The unique index”The handler can now produce a code, but nothing stops two rows from carrying the same one. That job belongs to the database, in one migration:
sqlx migrate add add_unique_code_indexCreating migrations/0002_add_unique_code_index.sqlsqlx-cli numbers the file after the highest existing migration, so it lands after 0001 — and
migrations run in filename order, which is why step 6’s file had to be 0001 in the first place.
CREATE UNIQUE INDEX links_code_key ON links (code);sqlx::migrate!() in main picks the new file up at startup, the same way it ran 0001 in
step 6. If your table still holds step 8’s two stub rows, the index creation fails, and it fails
loudly:
cargo run Running `target/debug/shortlink`
thread 'main' (13830072) panicked at src/main.rs:238:39:failed to run migrations: ExecuteMigration(Database(PgDatabaseError { severity: Error, code: "23505", message: "could not create unique index \"links_code_key\"", detail: Some("Key (code)=(stub) is duplicated."), hint: None, position: None, where: None, schema: Some("public"), table: Some("links"), column: None, data_type: None, constraint: Some("links_code_key"), file: Some("tuplesortvariants.c"), line: Some(1361), routine: Some("comparetup_index_btree") }), 2)note: run with `RUST_BACKTRACE=1` environment variable to display a backtraceThe thread id in the first line is a number from this machine; the rest is exactly what a duplicate stops you with. SQLSTATE 23505 is Postgres’s unique-violation code, and it is about to become part of the handler. If your table is empty, you can reproduce the failure by inserting two rows with the same code before starting the server:
docker exec shortlink-pg psql -U postgres -d shortlink -c "INSERT INTO links (code, url) VALUES ('stub', 'https://example.com/very/long/path'), ('stub', 'https://example.com/other')"The duplicates are throwaway development data, and there is no way to keep them under a unique index — every row would need its own code, and generation does not exist yet. Delete them and let the migration run:
docker exec shortlink-pg psql -U postgres -d shortlink -c 'DELETE FROM links'DELETE 2Start the server again. Migration 1 is already installed, so only 0002 runs, and this time it
succeeds. The receipt is in Postgres’s index catalogue:
docker exec shortlink-pg psql -U postgres -d shortlink -c "SELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'links'" indexname | indexdef----------------+----------------------------------------------------------------------- links_pkey | CREATE UNIQUE INDEX links_pkey ON public.links USING btree (id) links_code_key | CREATE UNIQUE INDEX links_code_key ON public.links USING btree (code)(2 rows)The retry
Section titled “The retry”With the index in place, the INSERT has a failure mode it never had before: 23505. The handler draws a code, inserts, and treats that one error as a reason to draw again:
async fn create_link( Extension(pool): Extension<PgPool>, Json(input): Json<CreateLink>,) -> Result<ApiResponse<Link>, StatusCode> { // The seed comes from the clock; every call to next_code advances the // state, so a retried insert draws a different code. let mut state = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("system clock is before 1970") .as_nanos() as u64;
for _ in 0..5 { let code = next_code(&mut state); match sqlx::query!( "INSERT INTO links (code, url) VALUES ($1, $2) RETURNING id, code, url", code, input.url ) .fetch_one(&pool) .await { Ok(row) => { return Ok(ApiResponse::with_status( StatusCode::CREATED, Link { code: row.code, url: row.url, }, )); } Err(err) => { // 23505: the unique index rejected the code. Any other // failure is a real error that a retry cannot fix. let retryable = err .as_database_error() .is_some_and(|db| matches!(db.kind(), sqlx::error::ErrorKind::UniqueViolation)); if !retryable { return Err(StatusCode::INTERNAL_SERVER_ERROR); } } } }
// Five collisions in a row out of 3.5 trillion codes is not bad luck, // it is a bug - but a 500 beats an infinite loop. Err(StatusCode::INTERNAL_SERVER_ERROR)}The seed comes from the clock, once per request; each call to next_code advances the state, so a
retried insert draws a different code. The loop gives up after five draws — five collisions in a
row out of 3.5 trillion codes is not bad luck, it is a bug, and a 500 beats an infinite loop.
Everything hinges on the error check: as_database_error() unwraps the sqlx error into Postgres’s
own, and ErrorKind::UniqueViolation is the one kind worth retrying. A connection failure or a
syntax error would return 500 immediately, because no amount of redrawing fixes those.
A collision in production is invisible from the outside — the request takes one extra round-trip
and still comes back 201. To watch the retry fire, pin the first draw to a code that already
exists in your table and log each draw. That is a temporary edit to the handler, not part of the
tag; this run used an eprintln! per draw:
Running `target/debug/shortlink`drawing code: Ymyw02fdrawing code: 4610vwbThe first draw hits the unique index and is discarded; the second one inserts:
HTTP/1.1 201 Createdcontent-type: application/jsoncontent-length: 61date: Sun, 18 Oct 2026 11:10:42 GMT
{"code":"4610vwb","url":"https://example.com/collision-demo"}Refresh the cache, then build offline
Section titled “Refresh the cache, then build offline”The INSERT changed shape — 'stub' is gone, $1 and $2 are in — so the .sqlx/ cache from
step 7 no longer matches the query. With the container running:
cargo sqlx preparequery data written to .sqlx in the current directory; please check this into version controlAnd the offline story still holds, which is the point of the cache:
touch src/main.rs && env -u DATABASE_URL cargo build Compiling shortlink v0.1.0 (/Users/rango/www/hackingwithrust/code/axum-sqlx-api) Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.47sSee it run
Section titled “See it run”Start the server and post two links:
cargo runcurl -si -X POST http://127.0.0.1:8000/links \ -H 'content-type: application/json' \ -d '{"url":"https://example.com/very/long/path"}'HTTP/1.1 201 Createdcontent-type: application/jsoncontent-length: 61date: Sun, 18 Oct 2026 11:09:43 GMT
{"code":"Ymyw02f","url":"https://example.com/very/long/path"}curl -si -X POST http://127.0.0.1:8000/links \ -H 'content-type: application/json' \ -d '{"url":"https://example.com/other"}'HTTP/1.1 201 Createdcontent-type: application/jsoncontent-length: 52date: Sun, 18 Oct 2026 11:09:43 GMT
{"code":"iFioI4c","url":"https://example.com/other"}Two posts, two codes, seven characters each — and different from the ones this run produced, because your seed came from your clock. The resolver redirects by the new codes:
curl -si http://127.0.0.1:8000/Ymyw02fHTTP/1.1 302 Foundlocation: https://example.com/very/long/pathcontent-length: 0date: Sun, 18 Oct 2026 11:09:51 GMTAnd the listing pages through real codes now:
curl -si 'http://127.0.0.1:8000/links?page=2&limit=1'HTTP/1.1 200 OKcontent-type: application/jsoncontent-length: 83date: Sun, 18 Oct 2026 11:09:51 GMT
{"page":2,"limit":1,"links":[{"code":"iFioI4c","url":"https://example.com/other"}]}Where this leaves the service
Section titled “Where this leaves the service”Every row now has its own seven-character code: generated in the handler from a clock-seeded
xorshift, guaranteed unique by the database, retried when a draw collides. The stub is gone, and
GET /{code} redirects to the link that was actually stored.
What the step does not do is make errors readable. An exhausted retry, a dead database and a malformed insert all come back as a bodyless 500 today — step 10 validates input and replaces those bare status codes with real error responses.
The end state is tagged api-ch09 in
the examples repository — git checkout api-ch09
inside axum-sqlx-api/. cargo test runs the encoder tests offline; with the container up, the
curl sequence above replays the whole service.
Next: validating input and returning proper errors. The track overview keeps the full list current as articles land.