Skip to content
gohacks.dev
Web

Build a URL Shortener in Go With Tests and a Database

Build a URL shortener in Go on SQLite with random base62 codes, collision retries, custom aliases, expiry, hit counting and tests for the awkward cases.

19 min read
Summarize in AI
Flat illustration of a database beside the table it holds.

A URL shortener is the project every web developer builds at least once, and it is a good one because the happy path takes an hour and the real work is everywhere else: what makes a code unique, what happens when two requests pick the same one, which URLs you must refuse, what a redirect should look like to a browser and a crawler, and how to count visits without losing any. Each of those is a small decision with a failure mode, and together they make a service that is correct instead of one that mostly works.

Tested with Go 1.25.6 and modernc.org/sqlite v1.50.0, macOS on Apple Silicon (arm64)

This project builds one with the standard library and a SQLite database, driven by tests. It belongs to the web cluster that ends in a production HTTP server, uses the redirect semantics from redirects and status codes, and the persistence ideas are those of handler dependency injection: a store behind a small struct, injected clock and code generator. Everything ran on Go 1.25.6 with modernc.org/sqlite v1.50.0, a pure Go SQLite driver, so there is no cgo toolchain to install.

What it does

Three endpoints. POST /api/links takes a target URL, an optional alias and an optional lifetime, and returns the short code. GET /{code} redirects to the target and records the visit. GET /api/links/{code} returns the target, the hit count and the creation time. Everything interesting is in how those three are made reliable.

The store

SQLite fits a first version well: one file, transactional, and fast enough for thousands of redirects per second on a single node. The schema has the code as the primary key, which does the uniqueness work for us.

store.goGo
package main

import (
	"context"
	"database/sql"
	"errors"
	"strings"
	"time"

	_ "modernc.org/sqlite"
)

var (
	ErrNotFound  = errors.New("link not found")
	ErrExpired   = errors.New("link expired")
	ErrCodeTaken = errors.New("code already in use")
)

type Store struct{ db *sql.DB }

func OpenStore(dsn string) (*Store, error) {
	db, err := sql.Open("sqlite", dsn)
	if err != nil {
		return nil, err
	}
	db.SetMaxOpenConns(1) // one writer; also keeps an in-memory database on a single connection
	_, err = db.Exec(`CREATE TABLE IF NOT EXISTS links (
		code       TEXT PRIMARY KEY,
		url        TEXT NOT NULL,
		created_at INTEGER NOT NULL,
		expires_at INTEGER,
		hits       INTEGER NOT NULL DEFAULT 0
	)`)
	if err != nil {
		db.Close()
		return nil, err
	}
	return &Store{db: db}, nil
}

func (s *Store) Close() error { return s.db.Close() }

// Create inserts a link. The primary key is the uniqueness check, so two requests cannot both win a code.
func (s *Store) Create(ctx context.Context, code, url string, now time.Time, ttl time.Duration) error {
	var expires any
	if ttl > 0 {
		expires = now.Add(ttl).Unix()
	}
	_, err := s.db.ExecContext(ctx,
		`INSERT INTO links (code, url, created_at, expires_at) VALUES (?, ?, ?, ?)`,
		code, url, now.Unix(), expires)
	if err != nil && strings.Contains(err.Error(), "constraint failed") {
		return ErrCodeTaken
	}
	return err
}

// Resolve returns the target and counts the visit in one statement, so concurrent visits cannot lose an increment.
func (s *Store) Resolve(ctx context.Context, code string, now time.Time) (string, error) {
	var url string
	err := s.db.QueryRowContext(ctx,
		`UPDATE links SET hits = hits + 1
		 WHERE code = ? AND (expires_at IS NULL OR expires_at > ?)
		 RETURNING url`, code, now.Unix()).Scan(&url)
	if err == nil {
		return url, nil
	}
	if !errors.Is(err, sql.ErrNoRows) {
		return "", err
	}
	// No row was updated: it either never existed or has expired.
	var one int
	switch err := s.db.QueryRowContext(ctx, `SELECT 1 FROM links WHERE code = ?`, code).Scan(&one); {
	case errors.Is(err, sql.ErrNoRows):
		return "", ErrNotFound
	case err != nil:
		return "", err
	}
	return "", ErrExpired
}

type Stats struct {
	URL       string `json:"url"`
	Hits      int    `json:"hits"`
	CreatedAt int64  `json:"created_at"`
}

func (s *Store) Stats(ctx context.Context, code string) (Stats, error) {
	var st Stats
	err := s.db.QueryRowContext(ctx, `SELECT url, hits, created_at FROM links WHERE code = ?`, code).
		Scan(&st.URL, &st.Hits, &st.CreatedAt)
	if errors.Is(err, sql.ErrNoRows) {
		return st, ErrNotFound
	}
	return st, err
}

Two statements here carry most of the weight. The INSERT does not first check whether the code exists. A check-then-insert is a race: two requests choose the same code, both see it free, and one insert fails or, worse, overwrites. Letting the primary key reject the duplicate is atomic, and the store converts that rejection into ErrCodeTaken for the caller to act on. The UPDATE ... RETURNING does the redirect lookup and the visit count in a single statement. If the two were separate (read the URL, then increment), concurrent visits could read the same count and write the same total, undercounting; SQLite has supported RETURNING since version 3.35, and the driver bundles a recent one.

The error check on "constraint failed" is deliberately loose: the driver reports a primary key violation through an error whose text contains it, and matching the text avoids importing driver internals for this one case. If you move to PostgreSQL, the equivalent is checking the SQLSTATE 23505 through the driver's error type.

The service: codes, validation, retries

shortener.goGo
package main

import (
	"context"
	"crypto/rand"
	"errors"
	"fmt"
	"net/url"
	"regexp"
	"slices"
	"time"
)

var (
	ErrInvalidURL = errors.New("url must be an absolute http or https address")
	ErrBadAlias   = errors.New("alias must be 3 to 32 letters, digits, dashes or underscores, and not reserved")
)

const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"

var (
	aliasRe  = regexp.MustCompile(`^[A-Za-z0-9_-]{3,32}$`)
	reserved = []string{"api", "healthz", "admin", "static", "favicon.ico", "robots.txt"}
)

// randomCode returns n base62 characters. Bytes of 248 or more are skipped, because 256 is not a multiple of 62
// and a plain modulo would make the first characters of the alphabet slightly more likely.
func randomCode(n int) (string, error) {
	out := make([]byte, 0, n)
	buf := make([]byte, 2*n)
	for len(out) < n {
		if _, err := rand.Read(buf); err != nil {
			return "", err
		}
		for _, b := range buf {
			if b >= 248 {
				continue
			}
			out = append(out, alphabet[b%62])
			if len(out) == n {
				break
			}
		}
	}
	return string(out), nil
}

type Shortener struct {
	Store   *Store
	Base    string
	Now     func() time.Time
	NewCode func() (string, error)
}

func NewShortener(store *Store, base string) *Shortener {
	return &Shortener{Store: store, Base: base, Now: time.Now, NewCode: func() (string, error) { return randomCode(7) }}
}

func validateTarget(raw string) (string, error) {
	if len(raw) == 0 || len(raw) > 2048 {
		return "", ErrInvalidURL
	}
	u, err := url.Parse(raw)
	if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil {
		return "", ErrInvalidURL
	}
	return u.String(), nil
}

// Create validates the target and stores it under the alias, or under a fresh random code.
func (s *Shortener) Create(ctx context.Context, target, alias string, ttl time.Duration) (string, error) {
	target, err := validateTarget(target)
	if err != nil {
		return "", err
	}
	if alias != "" {
		if !aliasRe.MatchString(alias) || slices.Contains(reserved, alias) {
			return "", ErrBadAlias
		}
		return alias, s.Store.Create(ctx, alias, target, s.Now(), ttl) // a taken alias is the caller's problem: ErrCodeTaken
	}
	for attempt := 0; attempt < 5; attempt++ {
		code, err := s.NewCode()
		if err != nil {
			return "", err
		}
		if slices.Contains(reserved, code) {
			continue
		}
		switch err := s.Store.Create(ctx, code, target, s.Now(), ttl); {
		case err == nil:
			return code, nil
		case errors.Is(err, ErrCodeTaken):
			continue // random collision: pick another
		default:
			return "", err
		}
	}
	return "", fmt.Errorf("could not find a free code after 5 attempts")
}

Notice where each rule lives. validateTarget accepts only absolute http and https URLs with a host and no embedded credentials, and caps the length at 2,048 bytes. That rules out javascript: and data: targets, which would turn the shortener into a delivery mechanism for script injection, and user:password@host forms, which are used to disguise phishing links. It also parses and re-serializes the URL, so what gets stored is the normalized form.

The code generator is a field, NewCode, which is what lets a test force a collision. Random code collisions are rare, and a retry loop that has never been executed is a retry loop you do not know works. randomCode uses rejection sampling, as its comment explains; with a modulo of a random byte, the first eight characters of the alphabet would come up about 1.5 percent more often than the rest. That bias is not a security hole for a shortener, but it costs nothing to avoid, and the same function will be copied into something where it matters.

The reserved list protects your own routes. A custom alias of api, or a randomly generated code that happens to spell admin, would shadow a path of the application itself.

The HTTP layer, and a tour

http.goGo
package main

import (
	"encoding/json"
	"errors"
	"net/http"
	"time"
)

func (s *Shortener) Routes() http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("POST /api/links", s.handleCreate)
	mux.HandleFunc("GET /api/links/{code}", s.handleStats)
	mux.HandleFunc("GET /{code}", s.handleRedirect)
	return mux
}

func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(v)
}

func (s *Shortener) handleCreate(w http.ResponseWriter, r *http.Request) {
	var in struct {
		URL      string `json:"url"`
		Alias    string `json:"alias"`
		TTLHours int    `json:"ttl_hours"`
	}
	dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<14))
	dec.DisallowUnknownFields()
	if err := dec.Decode(&in); err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "body must be JSON with a url"})
		return
	}
	code, err := s.Create(r.Context(), in.URL, in.Alias, time.Duration(in.TTLHours)*time.Hour)
	switch {
	case errors.Is(err, ErrInvalidURL), errors.Is(err, ErrBadAlias):
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
	case errors.Is(err, ErrCodeTaken):
		writeJSON(w, http.StatusConflict, map[string]string{"error": "that alias is taken"})
	case err != nil:
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not create the link"})
	default:
		writeJSON(w, http.StatusCreated, map[string]string{"code": code, "short_url": s.Base + "/" + code})
	}
}

func (s *Shortener) handleRedirect(w http.ResponseWriter, r *http.Request) {
	target, err := s.Store.Resolve(r.Context(), r.PathValue("code"), s.Now())
	switch {
	case errors.Is(err, ErrNotFound):
		http.NotFound(w, r)
	case errors.Is(err, ErrExpired):
		http.Error(w, "this link has expired", http.StatusGone)
	case err != nil:
		http.Error(w, "internal error", http.StatusInternalServerError)
	default:
		w.Header().Set("Cache-Control", "private, no-cache") // every visit must reach us, or it is not counted
		http.Redirect(w, r, target, http.StatusFound)
	}
}

func (s *Shortener) handleStats(w http.ResponseWriter, r *http.Request) {
	st, err := s.Store.Stats(r.Context(), r.PathValue("code"))
	if errors.Is(err, ErrNotFound) {
		writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such link"})
		return
	}
	if err != nil {
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
		return
	}
	writeJSON(w, http.StatusOK, st)
}

The program below runs the service end to end against an in-memory database, with a fake clock so expiry needs no sleeping, and prints the numbers behind the code length.

main.goGo
package main

import (
	"context"
	"fmt"
	"io"
	"math"
	"net/http"
	"net/http/httptest"
	"regexp"
	"strings"
	"time"
)

func main() {
	store, err := OpenStore("file::memory:")
	if err != nil {
		panic(err)
	}
	defer store.Close()
	svc := NewShortener(store, "https://sho.rt")
	clock := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
	svc.Now = func() time.Time { return clock }
	srv := httptest.NewServer(svc.Routes())
	defer srv.Close()

	client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
	post := func(body string) (int, string) {
		res, _ := client.Post(srv.URL+"/api/links", "application/json", strings.NewReader(body))
		b, _ := io.ReadAll(res.Body)
		res.Body.Close()
		return res.StatusCode, strings.TrimSpace(string(b))
	}
	get := func(path string) (int, string, string) {
		res, _ := client.Get(srv.URL + path)
		b, _ := io.ReadAll(res.Body)
		res.Body.Close()
		return res.StatusCode, res.Header.Get("Location"), strings.TrimSpace(string(b))
	}

	code, body := post(`{"url":"https://go.dev/doc/effective_go"}`)
	masked := regexp.MustCompile(`"code":"[A-Za-z0-9]{7}"`).ReplaceAllString(body, `"code":"<7 chars>"`)
	masked = regexp.MustCompile(`/[A-Za-z0-9]{7}"`).ReplaceAllString(masked, `/<7 chars>"`)
	fmt.Println("create:", code, masked)
	fmt.Println("create with alias:", func() string {
		c, b := post(`{"url":"https://pkg.go.dev/net/http","alias":"nethttp"}`)
		return fmt.Sprint(c, " ", b)
	}())
	fmt.Println("alias taken:", func() string {
		c, b := post(`{"url":"https://example.org","alias":"nethttp"}`)
		return fmt.Sprint(c, " ", b)
	}())
	fmt.Println("reserved alias:", func() string {
		c, b := post(`{"url":"https://example.org","alias":"api"}`)
		return fmt.Sprint(c, " ", b)
	}())
	for _, bad := range []string{`{"url":"javascript:alert(1)"}`, `{"url":"/relative"}`, `{"url":"https://user:[email protected]/"}`, `{"url":"ftp://files.example/"}`} {
		c, _ := post(bad)
		fmt.Printf("refused %-42s -> %d\n", bad, c)
	}

	for i := 0; i < 3; i++ {
		get("/nethttp")
	}
	st, loc, _ := get("/nethttp")
	fmt.Println("visit /nethttp:", st, "Location:", loc)
	_, _, stats := get("/api/links/nethttp")
	fmt.Println("stats:", stats)

	post(`{"url":"https://example.org/promo","alias":"promo","ttl_hours":24}`)
	st, _, _ = get("/promo")
	fmt.Println("promo before expiry:", st)
	clock = clock.Add(25 * time.Hour)
	st, _, msg := get("/promo")
	fmt.Println("promo 25 hours later:", st, msg)
	st, _, _ = get("/neverexisted")
	fmt.Println("unknown code:", st)
	_, err = svc.Create(context.Background(), "https://example.org", "", 0)
	fmt.Println("random code create error:", err)

	fmt.Println()
	space := func(n int) float64 { return math.Pow(62, float64(n)) }
	for _, n := range []int{5, 6, 7, 8} {
		links := 1e6
		fmt.Printf("%d chars: %.2e codes; chance a new code collides when %.0e links exist: %.1e\n", n, space(n), links, links/space(n))
	}
}
Text
create: 201 {"code":"<7 chars>","short_url":"https://sho.rt/<7 chars>"}
create with alias: 201 {"code":"nethttp","short_url":"https://sho.rt/nethttp"}
alias taken: 409 {"error":"that alias is taken"}
reserved alias: 400 {"error":"alias must be 3 to 32 letters, digits, dashes or underscores, and not reserved"}
refused {"url":"javascript:alert(1)"}              -> 400
refused {"url":"/relative"}                        -> 400
refused {"url":"https://user:[email protected]/"}    -> 400
refused {"url":"ftp://files.example/"}             -> 400
visit /nethttp: 302 Location: https://pkg.go.dev/net/http
stats: {"url":"https://pkg.go.dev/net/http","hits":4,"created_at":1767268800}
promo before expiry: 302
promo 25 hours later: 410 this link has expired
unknown code: 404
random code create error: <nil>

5 chars: 9.16e+08 codes; chance a new code collides when 1e+06 links exist: 1.1e-03
6 chars: 5.68e+10 codes; chance a new code collides when 1e+06 links exist: 1.8e-05
7 chars: 3.52e+12 codes; chance a new code collides when 1e+06 links exist: 2.8e-07
8 chars: 2.18e+14 codes; chance a new code collides when 1e+06 links exist: 4.6e-09

The first block of output is the contract. A random code is seven characters (masked in the output, since it differs every run), an alias is stored as given, a repeated alias is a 409 Conflict, and api is refused as reserved. The four bad targets all get 400: a script URL, a relative path, a URL with embedded credentials, and an ftp address. The redirect is a 302 with the Location header. After four visits the stats show the count, and a link created with a 24 hour lifetime works until the fake clock moves 25 hours forward, at which point it answers 410 Gone while a code that never existed stays 404. That distinction matters to crawlers and to people reading error reports, and it costs one extra query on the miss path only.

The last block is the arithmetic behind seven characters. Five give about 916 million codes, six give 57 billion, seven give 3.5 trillion and eight 218 trillion. At a million stored links, a fresh seven-character code collides with probability about 3 in 10 million, so the retry loop rarely runs, and with a million links you would expect it to trigger only a few times over the lifetime of the data. The birthday arithmetic says that building the first million links has roughly a one in seven chance of hitting a single collision at all (n squared over twice the space, about 0.14). That is exactly why it needs a test: the code path will run in production once in a blue moon, and the first execution must not be the real one.

Tests worth having

shortener_test.goGo
package main

import (
	"context"
	"errors"
	"net/http"
	"net/http/httptest"
	"strings"
	"sync"
	"testing"
	"time"
)

func newTestService(t *testing.T) *Shortener {
	t.Helper()
	store, err := OpenStore("file::memory:")
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { store.Close() })
	return NewShortener(store, "https://sho.rt")
}

func TestCollisionIsRetried(t *testing.T) {
	svc := newTestService(t)
	codes := []string{"AAAAAAA", "AAAAAAA", "BBBBBBB"} // the second call collides with the first
	svc.NewCode = func() (string, error) { c := codes[0]; codes = codes[1:]; return c, nil }
	ctx := context.Background()
	first, err := svc.Create(ctx, "https://example.org/1", "", 0)
	if err != nil || first != "AAAAAAA" {
		t.Fatalf("first: %q, %v", first, err)
	}
	second, err := svc.Create(ctx, "https://example.org/2", "", 0)
	if err != nil || second != "BBBBBBB" {
		t.Fatalf("second should have retried past the collision: %q, %v", second, err)
	}
}

func TestReservedCodesAreSkipped(t *testing.T) {
	svc := newTestService(t)
	codes := []string{"admin", "Zk3pQ9x"}
	svc.NewCode = func() (string, error) { c := codes[0]; codes = codes[1:]; return c, nil }
	got, err := svc.Create(context.Background(), "https://example.org", "", 0)
	if err != nil || got != "Zk3pQ9x" {
		t.Fatalf("got %q, %v", got, err)
	}
}

func TestConcurrentVisitsAreAllCounted(t *testing.T) {
	svc := newTestService(t)
	ctx := context.Background()
	if _, err := svc.Create(ctx, "https://example.org", "busy", 0); err != nil {
		t.Fatal(err)
	}
	var wg sync.WaitGroup
	for i := 0; i < 50; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			if _, err := svc.Store.Resolve(ctx, "busy", time.Now()); err != nil {
				t.Error(err)
			}
		}()
	}
	wg.Wait()
	st, err := svc.Store.Stats(ctx, "busy")
	if err != nil || st.Hits != 50 {
		t.Fatalf("hits = %d, err = %v; every visit must count", st.Hits, err)
	}
}

func TestTargetValidation(t *testing.T) {
	svc := newTestService(t)
	for _, target := range []string{"", "go.dev", "//go.dev", "javascript:alert(1)", "https://u:[email protected]", "https://" + strings.Repeat("a", 2100) + ".test"} {
		if _, err := svc.Create(context.Background(), target, "", 0); !errors.Is(err, ErrInvalidURL) {
			t.Errorf("%q: err = %v, want ErrInvalidURL", target[:min(len(target), 30)], err)
		}
	}
}

func TestRedirectIsATemporaryRedirect(t *testing.T) {
	svc := newTestService(t)
	if _, err := svc.Create(context.Background(), "https://go.dev/blog", "blog", 0); err != nil {
		t.Fatal(err)
	}
	rec := httptest.NewRecorder()
	svc.Routes().ServeHTTP(rec, httptest.NewRequest("GET", "/blog", nil))
	if rec.Code != http.StatusFound || rec.Header().Get("Location") != "https://go.dev/blog" {
		t.Errorf("status %d, location %q", rec.Code, rec.Header().Get("Location"))
	}
}

func TestRandomCodeUsesTheWholeAlphabet(t *testing.T) {
	seen := map[rune]bool{}
	for i := 0; i < 400; i++ {
		c, _ := randomCode(7)
		for _, r := range c {
			seen[r] = true
		}
	}
	if len(seen) != 62 {
		t.Errorf("saw %d distinct characters in 2,800 samples, want all 62", len(seen))
	}
}
Text
=== RUN   TestCollisionIsRetried
--- PASS: TestCollisionIsRetried (0.01s)
=== RUN   TestReservedCodesAreSkipped
--- PASS: TestReservedCodesAreSkipped (0.00s)
=== RUN   TestConcurrentVisitsAreAllCounted
--- PASS: TestConcurrentVisitsAreAllCounted (0.03s)
=== RUN   TestTargetValidation
--- PASS: TestTargetValidation (0.00s)
=== RUN   TestRedirectIsATemporaryRedirect
--- PASS: TestRedirectIsATemporaryRedirect (0.00s)
=== RUN   TestRandomCodeUsesTheWholeAlphabet
--- PASS: TestRandomCodeUsesTheWholeAlphabet (0.00s)
PASS
ok  	example.test/lab	1.427s

Two of those deserve a note. The concurrency test is the one that proves the single-statement update: with a read-then-write implementation and several connections, the final count would come out below 50 on some runs, and it passes here only if the counting is atomic. (With SetMaxOpenConns(1) SQLite serializes the statements anyway, which hides a naive implementation; the test earns its keep when someone later raises the pool size or moves to another database, which is exactly when a counting bug would appear.) And the collision test only works because NewCode is a field: the injected sequence forces the exact failure the retry loop exists for.

Other ways to make codes

Random codes are not the only option, and the alternatives are worth knowing because each trades something.

A counter encoded in base62 takes the row ID (1, 2, 3, ...) and writes it in base 62, so the thousandth link is g8 and the millionth is 4c92. Codes are short, collisions are impossible, and no retry loop exists. The cost is that codes are guessable and enumerable: anyone can walk /1, /2, /3 and read every link ever shortened, which is a privacy leak when targets are private documents. If links are public anyway, the counter is a fine choice.

A permuted counter fixes that by passing the ID through a reversible scramble before encoding it, so codes look random but remain unique by construction. Libraries such as Sqids do this; the Sqids generator on this site shows what the output looks like for a few inputs. It keeps the no-collision guarantee and the short length, at the price of a secret alphabet you must not lose and an algorithm you must trust, and it is obscurity rather than security: someone who collects enough codes and IDs can recover the pattern.

A hash of the target gives the same code for the same URL, which deduplicates for free. It leaks nothing but equality, and it makes expiry and per-user aliases awkward, since two users shortening the same URL share one row. I would only choose it when deduplication is a feature.

For a general-purpose shortener I pick random codes with a retry loop, because the failure mode is the gentlest: a rare retry, no guessable sequence, no secret to guard.

Scaling the redirect path

A shortener is read-heavy by a wide margin: one write creates a link that may be visited a million times. That shapes where the effort goes. The redirect handler does a primary-key lookup and, here, a counter write. Reads scale easily, since the table is tiny and the lookup is a single indexed row, and an in-memory cache with a short TTL in front of Resolve takes most traffic off the database. The write is the limit: SQLite serializes writers, so the counter caps the redirect rate at whatever one connection can commit. Two fixes work, and they can be combined. Buffer hit increments in memory and flush them in one transaction every second, accepting that a crash loses up to a second of counts. Or count at the edge, from access logs, and treat the database as the source of the mapping only.

Be aware also that a GET pattern in Go's mux matches HEAD requests, and link-preview bots and uptime checkers send them. In this implementation those would be counted as visits. If your numbers matter, check r.Method in the handler and resolve without counting for HEAD, or filter known bots by user agent at analysis time.

Testing against the database you will run

The tests above use an in-memory SQLite database, which is fast and isolated, and for a shortener that is the real engine, so what passes there passes in production. The risk comes later, if you move to PostgreSQL and keep testing against SQLite: the dialects differ in placeholders, in how RETURNING and upserts behave, and in how constraint errors are reported. At that point run the same test suite against the production engine in a container, using the approach in testing with a real database, and keep the in-memory run for the quick loop. A store test that cannot fail on a real unique violation is not testing the thing the store is for.

Running it for real

For operations, turn on SQLite's write-ahead log (PRAGMA journal_mode=WAL) and a busy timeout so brief contention waits instead of failing, and back the file up with VACUUM INTO or a tool that streams the WAL to object storage; a single file is only a convenience until it is the only copy. When one node is no longer enough, the move to PostgreSQL changes the driver and the constraint-violation check (SQLSTATE 23505) and nothing else in the service, which is the dividend of keeping SQL behind the Store. Log redirects with the code and status only, never the target URL if it can contain tokens, and decide deliberately what you keep about visitors: a hit counter needs no addresses, and not storing them is the cheapest privacy policy.

What to do next

A few things separate this from something I would put in front of the public. Add rate limiting to POST /api/links, since a free shortener is a magnet for abuse, and think about the abuse that is not volume: people will shorten phishing and malware links, and the shortener's domain inherits the damage. Check targets against a list such as Google's Safe Browsing before accepting them, and offer a way to report and disable a code. Count hits with a batched writer if redirects reach hundreds per second, because every redirect now performs a write; buffer increments in memory and flush them every second, accepting that a crash loses a second of counts. Put a cache in front of the GET /{code} path, as in HTTP response caching, only if you give up exact counts for cached hits.

Finally, think about what a short link promises: it keeps working. A 301 would let browsers and crawlers cache the mapping indefinitely, which saves requests but makes counting impossible and retargeting a lie; 302 keeps you in control. That choice, and the 410 for expired links, is documented in RFC 9110's redirection section. The driver's documentation is at pkg.go.dev/modernc.org/sqlite, and the SQL feature used for counting is described in the SQLite RETURNING documentation.

Also inDatabases
Taggedproject
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.