Building a Tiny URL Shortener in 30 Lines of Go

TL;DR

Build a complete URL shortener in 30 lines of Go using only stdlib. Generates short codes, handles redirects, stores in memory - perfect learning project.

URL shorteners like bit.ly and tinyurl.com seem complex, but the core concept is surprisingly simple: store a mapping between short codes and long URLs, then redirect when someone visits the short link.

Let's build one in Go with zero dependencies, just the standard library. We'll prioritize simplicity and readability over production features.

The Minimal Implementation

Here's our complete URL shortener in exactly 30 lines:

package main

import (
    "fmt"
    "log"
    "math/rand"
    "net/http"
    "strings"
    "time"
)

var urls = make(map[string]string)

func generateCode() string {
    const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
    code := make([]byte, 6)
    for i := range code {
        code[i] = chars[rand.Intn(len(chars))]
    }
    return string(code)
}

func handler(w http.ResponseWriter, r *http.Request) {
    path := strings.TrimPrefix(r.URL.Path, "/")
    if r.Method == "POST" {
        url := r.FormValue("url")
        code := generateCode()
        urls[code] = url
        fmt.Fprintf(w, "http://localhost:8080/%s\n", code)
    } else if longURL, exists := urls[path]; exists {
        http.Redirect(w, r, longURL, http.StatusMovedPermanently)
    } else {
        fmt.Fprintf(w, "URL shortener\nPOST url=https://example.com to shorten\nGET /{code} to redirect\n")
    }
}

func main() {
    rand.Seed(time.Now().UnixNano())
    log.Fatal(http.ListenAndServe(":8080", http.HandlerFunc(handler)))
}

That's it! A complete, working URL shortener in 30 lines.

How It Works

1. Data Storage

var urls = make(map[string]string)

We use a simple in-memory map to store short code → long URL mappings. In production, you'd use a database.

2. Code Generation

func generateCode() string {
    const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
    code := make([]byte, 6)
    for i := range code {
        code[i] = chars[rand.Intn(len(chars))]
    }
    return string(code)
}

Generates a random 6-character code using alphanumeric characters. This gives us 62^6 ≈ 56 billion possible combinations.

3. Single Handler Function

func handler(w http.ResponseWriter, r *http.Request) {
    path := strings.TrimPrefix(r.URL.Path, "/")
    
    if r.Method == "POST" {
        // Create short URL
        url := r.FormValue("url")
        code := generateCode()
        urls[code] = url
        fmt.Fprintf(w, "http://localhost:8080/%s\n", code)
    } else if longURL, exists := urls[path]; exists {
        // Redirect to long URL
        http.Redirect(w, r, longURL, http.StatusMovedPermanently)
    } else {
        // Show usage instructions
        fmt.Fprintf(w, "URL shortener\nPOST url=https://example.com to shorten\nGET /{code} to redirect\n")
    }
}

Testing It Out

Start the server:

go run shortener.go

Shorten a URL:

curl -X POST -d "url=https://www.google.com" http://localhost:8080
# Returns: http://localhost:8080/aB3kL9

Use the short URL:

curl -i http://localhost:8080/aB3kL9
# Returns: 301 redirect to https://www.google.com

Browser usage:

Visit http://localhost:8080 for instructions, then POST a form or use the redirects directly.

Extending the Basic Version

Add Collision Handling (32 lines)

func generateUniqueCode() string {
    for {
        code := generateCode()
        if _, exists := urls[code]; !exists {
            return code
        }
    }
}

Add Basic Validation (35 lines)

import "net/url"

func isValidURL(str string) bool {
    u, err := url.Parse(str)
    return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
}

// In handler:
if !isValidURL(url) {
    http.Error(w, "Invalid URL", http.StatusBadRequest)
    return
}

Add Statistics (45 lines)

var stats = make(map[string]int)

// In redirect section:
stats[path]++
fmt.Printf("Redirect to %s (visited %d times)\n", longURL, stats[path])

Production-Ready Features

Persistent Storage

import (
    "encoding/json"
    "os"
)

func saveData() {
    file, _ := os.Create("urls.json")
    defer file.Close()
    json.NewEncoder(file).Encode(urls)
}

func loadData() {
    file, err := os.Open("urls.json")
    if err != nil {
        return
    }
    defer file.Close()
    json.NewDecoder(file).Decode(&urls)
}

Custom Short Codes

func handler(w http.ResponseWriter, r *http.Request) {
    if r.Method == "POST" {
        url := r.FormValue("url")
        customCode := r.FormValue("code")
        
        var code string
        if customCode != "" && len(customCode) >= 3 {
            if _, exists := urls[customCode]; exists {
                http.Error(w, "Code already taken", http.StatusConflict)
                return
            }
            code = customCode
        } else {
            code = generateUniqueCode()
        }
        
        urls[code] = url
        fmt.Fprintf(w, "http://localhost:8080/%s\n", code)
    }
    // ... rest of handler
}

Expiration Dates

import "time"

type URLRecord struct {
    URL     string    `json:"url"`
    Expires time.Time `json:"expires"`
}

var urlData = make(map[string]URLRecord)

func cleanExpired() {
    for code, record := range urlData {
        if time.Now().After(record.Expires) {
            delete(urlData, code)
        }
    }
}

// Run cleanup periodically
go func() {
    for {
        time.Sleep(time.Hour)
        cleanExpired()
    }
}()

Rate Limiting

import (
    "golang.org/x/time/rate"
    "net"
)

var limiters = make(map[string]*rate.Limiter)

func getLimiter(ip string) *rate.Limiter {
    if limiter, exists := limiters[ip]; exists {
        return limiter
    }
    limiter := rate.NewLimiter(1, 5) // 1 request per second, burst of 5
    limiters[ip] = limiter
    return limiter
}

func handler(w http.ResponseWriter, r *http.Request) {
    ip, _, _ := net.SplitHostPort(r.RemoteAddr)
    if !getLimiter(ip).Allow() {
        http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
        return
    }
    // ... rest of handler
}

Performance Considerations

Memory Usage

Our basic version stores everything in memory. For production:

  • Use Redis for fast lookups
  • Use PostgreSQL/MySQL for persistence
  • Implement LRU cache for hot URLs

Scalability

// Use consistent hashing for distributed systems
import "hash/fnv"

func getShardID(code string) int {
    h := fnv.New32a()
    h.Write([]byte(code))
    return int(h.Sum32()) % numShards
}

Base62 Encoding

func base62Encode(num uint64) string {
    const chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
    if num == 0 {
        return "0"
    }
    
    result := ""
    for num > 0 {
        result = string(chars[num%62]) + result
        num /= 62
    }
    return result
}

// Use sequential IDs instead of random codes
var counter uint64 = 1000000 // Start at 1M to ensure min length

func generateSequentialCode() string {
    counter++
    return base62Encode(counter)
}

Docker Deployment

Dockerfile

FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY shortener.go .
RUN go build -o shortener shortener.go

FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/shortener .
EXPOSE 8080
CMD ["./shortener"]

docker-compose.yml

version: '3.8'
services:
  shortener:
    build: .
    ports:
      - "8080:8080"
    volumes:
      - ./data:/data
    environment:
      - DATA_FILE=/data/urls.json

Testing

Unit Tests

package main

import (
    "net/http"
    "net/http/httptest"
    "strings"
    "testing"
)

func TestShortenURL(t *testing.T) {
    urls = make(map[string]string) // Reset state
    
    req := httptest.NewRequest("POST", "/", strings.NewReader("url=https://example.com"))
    req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
    
    w := httptest.NewRecorder()
    handler(w, req)
    
    if w.Code != http.StatusOK {
        t.Errorf("Expected status 200, got %d", w.Code)
    }
    
    response := w.Body.String()
    if !strings.Contains(response, "http://localhost:8080/") {
        t.Errorf("Expected shortened URL in response, got: %s", response)
    }
}

func TestRedirect(t *testing.T) {
    urls = map[string]string{"test123": "https://example.com"}
    
    req := httptest.NewRequest("GET", "/test123", nil)
    w := httptest.NewRecorder()
    handler(w, req)
    
    if w.Code != http.StatusMovedPermanently {
        t.Errorf("Expected status 301, got %d", w.Code)
    }
    
    location := w.Header().Get("Location")
    if location != "https://example.com" {
        t.Errorf("Expected redirect to https://example.com, got: %s", location)
    }
}

Load Testing

# Install hey for load testing
go install github.com/rakyll/hey@latest

# Test URL creation
echo "url=https://example.com" | hey -n 1000 -c 10 -m POST -D /dev/stdin http://localhost:8080/

# Test redirects (after creating some URLs)
hey -n 1000 -c 10 http://localhost:8080/someCode

Security Considerations

Input Sanitization

import (
    "net/url"
    "strings"
)

func sanitizeURL(input string) (string, error) {
    input = strings.TrimSpace(input)
    
    if !strings.HasPrefix(input, "http://") && !strings.HasPrefix(input, "https://") {
        input = "https://" + input
    }
    
    u, err := url.Parse(input)
    if err != nil {
        return "", err
    }
    
    // Block internal networks
    if strings.Contains(u.Host, "localhost") || strings.Contains(u.Host, "127.0.0.1") {
        return "", fmt.Errorf("internal URLs not allowed")
    }
    
    return u.String(), nil
}

HTTPS Redirect

func redirectToHTTPS(w http.ResponseWriter, r *http.Request) {
    if r.Header.Get("X-Forwarded-Proto") != "https" {
        target := "https://" + r.Host + r.URL.Path
        http.Redirect(w, r, target, http.StatusMovedPermanently)
        return
    }
}

Conclusion

In just 30 lines of Go, we built a fully functional URL shortener that demonstrates:

  • HTTP request handling
  • Random code generation
  • In-memory data storage
  • HTTP redirects
  • Form processing

The beauty of Go's standard library shines here—no external dependencies needed for a working web service.

This minimal version is perfect for:

  • Learning Go web programming
  • Prototyping ideas quickly
  • Understanding URL shortener fundamentals
  • Building more complex services

For production use, add persistence, validation, rate limiting, and monitoring. But this tiny implementation proves that sometimes the best solutions are the simplest ones.