Skip to content
Select theme

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:

Cargo.toml
[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"] }

Above the handler structs in src/main.rs goes the section the whole step is built on:

src/main.rs
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:

Terminal window
cargo test
running 3 tests
test tests::encodes_the_boundaries ... ok
test tests::the_sequence_keeps_moving ... ok
test 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.00s

The order of those lines varies between runs — the test harness runs them in parallel.

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:

62^7 = 3521614606208
to_base62(0) = 0
to_base62(61) = z
to_base62(62) = 10
to_base62(3521614606207) = zzzzzzz
next code from seed 42: Kg5v4W8
next code from seed 42: 8nhgmYU
next code from seed 42: 218FsgA
next code from seed 42: X2BzxD5
next code from seed 42: QGmT8YI

to_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 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:

Terminal window
sqlx migrate add add_unique_code_index
Creating migrations/0002_add_unique_code_index.sql

sqlx-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.

migrations/0002_add_unique_code_index.sql
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:

Terminal window
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 backtrace

The 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:

Terminal window
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:

Terminal window
docker exec shortlink-pg psql -U postgres -d shortlink -c 'DELETE FROM links'
DELETE 2

Start 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:

Terminal window
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)

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:

src/main.rs
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: Ymyw02f
drawing code: 4610vwb

The first draw hits the unique index and is discarded; the second one inserts:

HTTP/1.1 201 Created
content-type: application/json
content-length: 61
date: Sun, 18 Oct 2026 11:10:42 GMT
{"code":"4610vwb","url":"https://example.com/collision-demo"}

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:

Terminal window
cargo sqlx prepare
query data written to .sqlx in the current directory; please check this into version control

And the offline story still holds, which is the point of the cache:

Terminal window
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.47s

Start the server and post two links:

Terminal window
cargo run
Terminal window
curl -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 Created
content-type: application/json
content-length: 61
date: Sun, 18 Oct 2026 11:09:43 GMT
{"code":"Ymyw02f","url":"https://example.com/very/long/path"}
Terminal window
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 Created
content-type: application/json
content-length: 52
date: 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:

Terminal window
curl -si http://127.0.0.1:8000/Ymyw02f
HTTP/1.1 302 Found
location: https://example.com/very/long/path
content-length: 0
date: Sun, 18 Oct 2026 11:09:51 GMT

And the listing pages through real codes now:

Terminal window
curl -si 'http://127.0.0.1:8000/links?page=2&limit=1'
HTTP/1.1 200 OK
content-type: application/json
content-length: 83
date: Sun, 18 Oct 2026 11:09:51 GMT
{"page":2,"limit":1,"links":[{"code":"iFioI4c","url":"https://example.com/other"}]}

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.