Skip to content
ExponaBLOG
GO PROGRAMMING & PRODUCTION APIS · DAY 072 / 100

Building a URL shortener in Go: best practices explained

A small project with real concepts.

Expona Admin8 min read

URL shorteners power tracking links across every major platform—from email campaigns to social media. Building a URL shortener in Go: best practices explained means handling code generation, persistent storage, caching, and fast redirects in a system that must scale from thousands to millions of requests daily. This article walks through a production-grade implementation that covers the full stack, from encoding strategies to analytics collection.

Why build a URL shortener

A URL shortener is a deceptively simple service that teaches real production concepts. You need to generate unique, collision-free identifiers; store and retrieve mappings at scale; cache aggressively; and serve redirects with minimal latency. Most importantly, you'll learn to balance consistency, performance, and operational complexity—skills that transfer directly to larger systems.

In a ride-hailing or e-commerce context, short URLs let you embed tracking codes in SMS, emails, and QR codes without bloating message size. A SaaS platform might use shorteners to obfuscate internal IDs or create shareable links. The underlying patterns—ID generation, caching, async logging—appear everywhere in production.

Generate codes: Base62 encoding of an ID or random bytes

The first decision is how to produce short, unique codes. Two approaches dominate: sequential IDs encoded in Base62, or random byte sequences. Sequential IDs are deterministic and compact; random codes avoid leaking sequence information and feel more obfuscated.

Sequential ID approach: Use a database sequence or a distributed ID generator (like Snowflake) to produce a numeric ID, then encode it in Base62. Base62 uses 0–9, a–z, and A–Z (62 characters), so a 7-character code can represent numbers up to 62^7 ≈ 3.6 trillion mappings.

go
package shortener

const base62Charset = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"

// EncodeBase62 converts a numeric ID to a Base62 string.
func EncodeBase62(id int64) string {
	if id == 0 {
		return "0"
	}
	var result strings.Builder
	for id > 0 {
		result.WriteByte(base62Charset[id%62])
		id /= 62
	}
	// Reverse the result
	runes := []rune(result.String())
	for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
		runes[i], runes[j] = runes[j], runes[i]
	}
	return string(runes)
}

// DecodeBase62 converts a Base62 string back to a numeric ID.
func DecodeBase62(code string) int64 {
	var id int64
	for _, ch := range code {
		id = id*62 + int64(strings.IndexByte(base62Charset, byte(ch)))
	}
	return id
}

Random bytes approach: Generate 6–8 random bytes and hex-encode or Base62-encode them. This feels more like a typical short URL (e.g., "abc123xyz") and doesn't leak usage patterns. The tradeoff is a tiny collision risk that you must handle in the database.

go
import (
	"crypto/rand"
	"encoding/base64"
)

// GenerateRandomCode produces a short random code.
func GenerateRandomCode(length int) (string, error) {
	bytes := make([]byte, length)
	if _, err := rand.Read(bytes); err != nil {
		return "", err
	}
	// Use URL-safe Base64 without padding
	code := base64.RawURLEncoding.EncodeToString(bytes)
	return code[:length], nil
}

For most teams, sequential + Base62 offers simplicity and predictability. If you need to hide scale or obfuscate IDs, go random. Either way, generate the code before inserting the mapping—your database insert will validate uniqueness.

Store mappings: Postgres with a unique index

The mapping layer is straightforward: a table linking short codes to long URLs, with metadata. Add a unique index on the code column to prevent duplicates and catch collisions fast.

go
// Migration or schema setup
CREATE TABLE url_mappings (
    id BIGSERIAL PRIMARY KEY,
    code VARCHAR(20) NOT NULL UNIQUE,
    long_url TEXT NOT NULL,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    expires_at TIMESTAMP,
    click_count INT DEFAULT 0,
    user_id BIGINT
);

CREATE INDEX idx_code ON url_mappings(code);
CREATE INDEX idx_user_id ON url_mappings(user_id);

In your Go service, use a prepared statement to insert and handle constraint violations gracefully.

go
import "database/sql"

type URLMapping struct {
	Code    string
	LongURL string
	UserID  *int64
}

func (db *Database) CreateMapping(ctx context.Context, m URLMapping) error {
	query := `
		INSERT INTO url_mappings (code, long_url, user_id, created_at)
		VALUES ($1, $2, $3, CURRENT_TIMESTAMP)
		RETURNING id
	`
	err := db.conn.QueryRowContext(ctx, query, m.Code, m.LongURL, m.UserID).Scan(&m.ID)
	if err != nil {
		if strings.Contains(err.Error(), "unique constraint") {
			return ErrCodeExists
		}
		return err
	}
	return nil
}

func (db *Database) GetMapping(ctx context.Context, code string) (string, error) {
	var longURL string
	query := "SELECT long_url FROM url_mappings WHERE code = $1"
	err := db.conn.QueryRowContext(ctx, query, code).Scan(&longURL)
	if err == sql.ErrNoRows {
		return "", ErrNotFound
	}
	return longURL, err
}

Use connection pooling (pgx or pq with a pool) to handle concurrent creates and lookups. Postgres is forgiving for this workload; you rarely need sharding unless you're storing billions of mappings.

Most URL shortener traffic concentrates on a small fraction of links—the Zipfian distribution in action. Cache the hot ones in Redis to reduce database load and latency.

On a redirect request, check Redis first. On a cache miss, fetch from Postgres and write back to Redis with a TTL. On creation, populate the cache immediately.

go
import "github.com/redis/go-redis/v9"

type CachedStore struct {
	redis *redis.Client
	db    *Database
	ttl   time.Duration
}

func (cs *CachedStore) GetMapping(ctx context.Context, code string) (string, error) {
	// Try Redis
	longURL, err := cs.redis.Get(ctx, "url:"+code).Result()
	if err == nil {
		return longURL, nil
	}
	if err != redis.Nil {
		// Redis error; continue to DB
		log.Printf("redis error: %v", err)
	}

	// Fall back to database
	longURL, err = cs.db.GetMapping(ctx, code)
	if err != nil {
		return "", err
	}

	// Populate cache
	_ = cs.redis.Set(ctx, "url:"+code, longURL, cs.ttl).Err()
	return longURL, nil
}

func (cs *CachedStore) CreateMapping(ctx context.Context, m URLMapping) error {
	if err := cs.db.CreateMapping(ctx, m); err != nil {
		return err
	}
	// Cache the mapping immediately
	_ = cs.redis.Set(ctx, "url:"+m.Code, m.LongURL, cs.ttl).Err()
	return nil
}

Set a reasonable TTL—anywhere from 1 to 24 hours depending on your use case. Short-lived links don't need caching; evergreen links like tracking codes benefit most. Monitor cache hit rates; ratios above 80% suggest your TTL is appropriate.

Redirect fast: 301 or 302 with analytics in the background

The redirect endpoint must be blazingly fast. Use a 301 (permanent) or 302 (temporary) status, then fire off analytics asynchronously so the client doesn't wait.

301 vs. 302: Use 301 for truly permanent links; browsers and intermediaries cache it, reducing future requests to your service. Use 302 for temporary links or if you might change the destination. For analytics, 302 is safer because it ensures each request hits your service.

go
import (
	"net/http"
	"github.com/go-chi/chi/v5"
)

func (s *Service) RedirectHandler(w http.ResponseWriter, r *http.Request) {
	code := chi.URLParam(r, "code")

	longURL, err := s.store.GetMapping(r.Context(), code)
	if err != nil {
		http.NotFound(w, r)
		return
	}

	// Record click asynchronously
	go s.recordClick(r.Context(), code, r.Header.Get("User-Agent"), r.RemoteAddr)

	// Redirect
	http.Redirect(w, r, longURL, http.StatusMovedPermanently)
}

func (s *Service) recordClick(ctx context.Context, code, userAgent, ip string) {
	// Use a non-blocking channel or background job queue
	select {
	case s.analytics <- ClickEvent{
		Code:      code,
		UserAgent: userAgent,
		IP:        ip,
		Timestamp: time.Now(),
	}:
	default:
		// Channel full; drop event to avoid blocking redirect
		log.Println("analytics queue full")
	}
}

Send analytics to a queue (channel, Kafka, or a job processor like Temporal) rather than blocking the redirect. Update click counts in Postgres asynchronously, in batches if possible. This keeps response time under 10ms even during traffic spikes.

Common mistakes

  • Blocking on analytics. Never make the client wait for logging. The redirect must complete before any slow I/O.
  • Ignoring cache invalidation. If a URL mapping changes (rare but possible), invalidate Redis immediately. Without it, clients see stale redirects for up to your TTL.
  • Collision handling. If using random codes, implement retry logic for inserts. If you hit a unique constraint violation, generate a new code and try again.
  • No rate limiting. A shortener without limits becomes a spam vector. Use middleware to cap requests per IP or per API key.
  • Storing credentials in URLs. Never log or cache the full URL if it contains passwords or API keys. Sanitize before storage.

Key takeaways

  • Choose ID generation based on your needs: sequential + Base62 for simplicity, random for obfuscation.
  • Postgres with a unique index handles collisions gracefully and stores the canonical mapping.
  • Redis caching is essential; aim for 80%+ hit rates to drop database load significantly.
  • Redirects must complete in microseconds; push analytics and updates to background jobs.
  • Use 301 for permanent links to leverage browser caching; use 302 for tracking to ensure each request flows through your service.
  • Handle edge cases: rate limiting, cache invalidation, collision retries, and URL sanitization.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles