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.