How to Build a URL Shortener in Go

Web
Build a Go URL shortener by mapping short keys to long URLs in an HTTP handler.

How to Build a URL Shortener in Go

You paste a link into a group chat. It's three hundred characters of tracking parameters. The message gets cut off. Your friends see ... and ignore it. You need a shortener. You could sign up for a service, but you want to own the data, or you just want to see how the plumbing works. Go makes this straightforward. The standard library handles the HTTP, and the logic is just a map lookup.

The coat check analogy

A URL shortener is a coat check for links. You hand over the long, awkward URL. The clerk gives you a ticket with a short code. Later, you show the ticket, and the clerk hands back the coat.

In code, the coat check is a map. The ticket is the key. The coat is the value. The redirect is the clerk handing the coat back. The server stores the mapping. When a request arrives with a key, the server looks up the value and tells the browser to fetch the target URL. The browser does the actual navigation. Your server just points the way.

A shortener is a map with a redirect. Keep the lookup fast.

Minimal example

Here's the simplest shortener: a map, a handler, and a redirect.

package main

import (
	"net/http"
)

// links stores the mapping from short key to full URL.
var links = map[string]string{
	"go": "https://golang.org",
}

func main() {
	// Register handler for /s/ prefix.
	http.HandleFunc("/s/", func(w http.ResponseWriter, r *http.Request) {
		// Extract key from path after /s/.
		key := r.URL.Path[3:]
		if target, ok := links[key]; ok {
			// 302 Found tells the browser to fetch the target URL.
			http.Redirect(w, r, target, http.StatusFound)
		} else {
			http.NotFound(w, r)
		}
	})
	// Start server on port 8080.
	http.ListenAndServe(":8080", nil)
}

Go's http package expects handlers to follow the signature func(http.ResponseWriter, *http.Request). This is the universal contract for web servers in Go. Stick to it, and you can swap servers or add middleware later without rewriting your logic.

What happens at runtime

When you run this, http.ListenAndServe blocks the main goroutine. It waits for connections on port 8080. The nil argument tells the server to use the default http.DefaultServeMux. This mux holds the routes you registered with HandleFunc.

When a browser hits http://localhost:8080/s/go, the router matches the /s/ prefix. It calls your handler function. Inside the handler, r.URL.Path contains /s/go. Slicing [3:] extracts go. The map lookup checks if go exists. If it does, http.Redirect writes a 302 status code and a Location header pointing to https://golang.org. The browser receives the response, sees the header, and automatically fetches the new URL.

If the key is missing, the map lookup returns ok = false. The handler calls http.NotFound, which writes a 404 status and a default error page.

The redirect lives in the headers. The browser follows the path.

Realistic implementation

Real shorteners generate unique keys and allow creating links. You need a way to store new mappings and handle concurrent requests safely.

Here's the data structure. It uses a mutex to protect the map from concurrent writes.

package main

import (
	"crypto/rand"
	"encoding/hex"
	"net/http"
	"sync"
)

// Store holds the links and a mutex for safe concurrent access.
type Store struct {
	mu    sync.Mutex
	links map[string]string
}

// NewStore creates a store with an initialized map.
func NewStore() *Store {
	return &Store{
		links: make(map[string]string),
	}
}

// GenerateKey creates a random 8-character hex string.
func GenerateKey() string {
	b := make([]byte, 4)
	// Read 4 random bytes from the crypto package.
	_, _ = rand.Read(b)
	// Encode bytes to hex for a URL-safe string.
	return hex.EncodeToString(b)
}

Go programs often run handlers in separate goroutines. If two requests try to write to the map at the same time, the program crashes. Wrap shared state in a sync.Mutex. The receiver name s is standard for Store methods. One or two letters matching the type is the convention. Never use this or self.

The creation handler reads the long URL from the request body and returns the short code. It validates the method and decodes JSON.

func (s *Store) handleCreate(w http.ResponseWriter, r *http.Request) {
	// Only allow POST requests for creating links.
	if r.Method != http.MethodPost {
		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		return
	}

	var payload struct {
		URL string `json:"url"`
	}
	// Decode JSON body; fail fast if format is wrong.
	if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}

	s.mu.Lock()
	defer s.mu.Unlock()
}

The json package uses struct tags to map JSON keys to Go fields. The tag json:"url" tells the decoder to look for the url key in the JSON payload. Without the tag, it would look for URL. The defer statement ensures the mutex unlocks even if the function returns early. Always pair Lock with defer Unlock. It prevents deadlocks if you add more return statements later.

Here's the rest of the handler. It generates the key, stores the link, and responds with JSON.

// Generate a random key and store the mapping.
key := GenerateKey()
s.links[key] = payload.URL

// Write JSON response with the short key.
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"key": key})
}

The redirect handler looks up the key and sends the browser to the target. It uses a 301 status for permanent redirects.

func (s *Store) handleRedirect(w http.ResponseWriter, r *http.Request) {
	// Extract key from the path after /s/.
	key := r.URL.Path[3:]

	s.mu.Lock()
	defer s.mu.Unlock()

	target, ok := s.links[key]
	if !ok {
		http.NotFound(w, r)
		return
	}

	// 301 Moved Permanently caches the redirect in the browser.
	http.Redirect(w, r, target, http.StatusMovedPermanently)
}

Use 301 for permanent redirects so browsers cache the result. Use 302 for temporary redirects. URL shorteners usually want 301 to reduce load on your server. The browser stores the mapping locally after the first visit, so subsequent clicks skip your server entirely.

Lock the map. Use 301 for permanent links. The mutex keeps the data safe.

Pitfalls and errors

If you slice the path without checking length, you panic. r.URL.Path might be just /s/. Slicing [3:] on a 3-character string returns an empty string, which is safe. But if the path is shorter, the runtime crashes. Check the length before slicing.

The runtime panics with runtime error: slice bounds out of range [:3] with length 2 if the path is too short. Add a guard: if len(r.URL.Path) < 3 { http.NotFound(w, r); return }.

Without the mutex, the runtime detects concurrent access and panics. The error is fatal error: concurrent map read and map write. This kills the server immediately. Always lock the map during reads and writes.

If you use math/rand instead of crypto/rand, the keys are predictable. An attacker can iterate through the key space and find valid links. Use crypto/rand for security-sensitive identifiers.

Check bounds. Lock the map. The runtime panics if you don't.

When to use what

Use an in-memory map when you are prototyping or the link set fits in RAM and restarts are acceptable. Use a database like SQLite or PostgreSQL when you need persistence across restarts or the dataset exceeds available memory. Use a distributed cache like Redis when you have multiple server instances and need shared state with low latency. Use a third-party API when you don't want to maintain infrastructure and can accept external dependencies. Use base62 encoding of an auto-increment ID when you want predictable, sequential short codes. Use random hex keys when you want to prevent users from guessing valid links by iterating through the key space.

Pick storage by persistence. Pick keys by security.

Where to go next