How to Build a URL Shortener in Redis
URL shorteners turn long URLs into compact codes that redirect to the original destination. Behind every short link is a simple lookup: given a code like abc123, return the full URL. Redis excels at this because the core operation is a key-value lookup, and Redis handles millions of these per second with sub-millisecond latency.
This guide covers two approaches: basic shortening for simple redirect services, and shortening with click tracking for services that need analytics. Both use Redis strings as the foundation, with the second approach adding counters and sorted sets for visit data.
Which Redis data types we will use
String is used in three ways in this implementation:
- As the URL mapping where the key is the short code and the value is the destination URL. One
GETcommand returns the redirect target. This is Redis at its simplest: pure key-value storage with O(1) lookups. - As an ID counter to generate unique short codes. The
INCRcommand atomically increments a counter and returns the new value. Convert that number to base62 (a-z, A-Z, 0-9) and you have a unique, compact short code with no collision checks needed. - As a click counter for simple click totals. One
INCRper click gives you a running total without the overhead of storing individual click records.
Hash stores URL metadata when you need more than just the destination. Creation timestamp, creator ID, expiration date, or custom slugs all fit naturally as hash fields. You can update individual fields without rewriting the entire record.
Sorted Set tracks click timestamps for analytics. Each click becomes a member with its timestamp as the score. This lets you query "clicks in the last hour" or "clicks between Monday and Friday" efficiently. The sorted set keeps click history ordered by time automatically.
Creating short links with key-value lookups
The simplest URL shortener needs two operations: create a short code that maps to a URL, and look up a URL by its short code. A Redis string handles both. The key is the short code, the value is the destination URL. Creating a link is SET, looking it up is GET.
You need to generate unique short codes. Common approaches include incrementing a counter and encoding it in base62, generating random strings, or hashing the URL. The examples below use a counter-based approach with INCR because it guarantees uniqueness without collision checks. The counter gives you a number, and you convert it to a short string using base62 (a-z, A-Z, 0-9).
The lookup is a single GET command. If the key exists, redirect to the URL. If not, return a 404. This is about as fast as any database operation can be.
- Redis
- Python
- TypeScript
- Go
# Generate a unique ID using a counter
INCR shorturl:counter
> 1000001
# Store the mapping (short code -> URL)
# The code "abc123" would come from base62-encoding the counter
SET shorturl:abc123 "https://example.com/very/long/path/to/page"
> OK
# Look up a short code
GET shorturl:abc123
> "https://example.com/very/long/path/to/page"
# Check if a code exists before redirecting
EXISTS shorturl:abc123
> 1 (exists)
EXISTS shorturl:invalid
> 0 (does not exist)
import string
BASE62 = string.ascii_lowercase + string.ascii_uppercase + string.digits
def encode_base62(num: int) -> str:
"""Convert a number to a base62 string."""
if num == 0:
return BASE62[0]
result = []
while num:
num, remainder = divmod(num, 62)
result.append(BASE62[remainder])
return ''.join(reversed(result))
def create_short_url(client, url: str) -> str:
"""Create a short code for a URL."""
# Get a unique ID from the counter
url_id = client.incr('shorturl:counter')
# Convert to base62 for a compact code
code = encode_base62(url_id)
# Store the mapping
client.set(f'shorturl:{code}', url)
return code
def get_url(client, code: str) -> str | None:
"""Look up the original URL for a short code."""
return client.get(f'shorturl:{code}')
const BASE62 = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
function encodeBase62(num: number): string {
if (num === 0) return BASE62[0];
let result = '';
while (num > 0) {
result = BASE62[num % 62] + result;
num = Math.floor(num / 62);
}
return result;
}
async function createShortUrl(client: Redis, url: string): Promise<string> {
// Get a unique ID from the counter
const urlId = await client.incr('shorturl:counter');
// Convert to base62 for a compact code
const code = encodeBase62(urlId);
// Store the mapping
await client.set(`shorturl:${code}`, url);
return code;
}
async function getUrl(client: Redis, code: string): Promise<string | null> {
return await client.get(`shorturl:${code}`);
}
const base62 = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
func encodeBase62(num int64) string {
if num == 0 {
return string(base62[0])
}
var result []byte
for num > 0 {
result = append([]byte{base62[num%62]}, result...)
num /= 62
}
return string(result)
}
func createShortURL(ctx context.Context, client *redis.Client, url string) (string, error) {
// Get a unique ID from the counter
urlID, err := client.Incr(ctx, "shorturl:counter").Result()
if err != nil {
return "", err
}
// Convert to base62 for a compact code
code := encodeBase62(urlID)
// Store the mapping
err = client.Set(ctx, "shorturl:"+code, url, 0).Err()
return code, err
}
func getURL(ctx context.Context, client *redis.Client, code string) (string, error) {
return client.Get(ctx, "shorturl:"+code).Result()
}
Adding analytics: counting clicks and tracking visits
Most URL shorteners need analytics: how many times was a link clicked, when, and from where. Redis can track this alongside the redirect lookup with minimal additional latency. The key insight is that you can increment counters and record visits without blocking the HTTP redirect.
For each click, you want to increment a total counter and record the timestamp. A simple counter uses INCR on a separate key. For time-series data, a sorted set works well: the score is the timestamp, and the member is a unique click ID or the timestamp itself. This lets you query clicks within a time range using ZRANGEBYSCORE.
To avoid slowing down redirects, use pipelining or fire-and-forget writes. The redirect can return immediately after the GET, while the analytics writes happen asynchronously. For high-traffic links, consider batching clicks in memory and flushing periodically.
- Redis
- Python
- TypeScript
- Go
# Store URL with metadata using a hash
HSET shorturl:abc123 url "https://example.com/page" created_at 1699900000
> 2 (fields set)
# On each click: increment counter
INCR shorturl:abc123:clicks
> 1
# Record click timestamp in sorted set (score = timestamp)
ZADD shorturl:abc123:clicklog 1699900060 "1699900060:click1"
> 1
# Get total clicks
GET shorturl:abc123:clicks
> "42"
# Get clicks in the last hour
ZRANGEBYSCORE shorturl:abc123:clicklog 1699896460 1699900060
> ["1699896500:click1", "1699898000:click2", ...]
# Get click count for a time range
ZCOUNT shorturl:abc123:clicklog 1699896460 1699900060
> 15
# Get URL and increment click in one round trip (pipeline)
GET shorturl:abc123
INCR shorturl:abc123:clicks
import time
import uuid
def create_short_url_with_metadata(client, url: str) -> str:
"""Create a short URL with tracking metadata."""
url_id = client.incr('shorturl:counter')
code = encode_base62(url_id)
# Store URL and creation time
client.hset(f'shorturl:{code}', mapping={
'url': url,
'created_at': int(time.time())
})
return code
def get_url_and_track(client, code: str) -> str | None:
"""Look up URL and record the click."""
# Use pipeline to batch commands
pipe = client.pipeline()
pipe.hget(f'shorturl:{code}', 'url')
pipe.incr(f'shorturl:{code}:clicks')
# Record click with timestamp
now = time.time()
click_id = f'{now}:{uuid.uuid4().hex[:8]}'
pipe.zadd(f'shorturl:{code}:clicklog', {click_id: now})
results = pipe.execute()
return results[0] # The URL
def get_click_stats(client, code: str, start: float = 0, end: float = None) -> dict:
"""Get click statistics for a short URL."""
if end is None:
end = time.time()
total = client.get(f'shorturl:{code}:clicks') or 0
recent = client.zcount(f'shorturl:{code}:clicklog', start, end)
return {'total': int(total), 'in_range': recent}
async function createShortUrlWithMetadata(client: Redis, url: string): Promise<string> {
const urlId = await client.incr('shorturl:counter');
const code = encodeBase62(urlId);
// Store URL and creation time
await client.hset(`shorturl:${code}`, {
url: url,
created_at: Math.floor(Date.now() / 1000)
});
return code;
}
async function getUrlAndTrack(client: Redis, code: string): Promise<string | null> {
const now = Date.now() / 1000;
const clickId = `${now}:${Math.random().toString(36).slice(2, 10)}`;
// Use pipeline to batch commands
const pipeline = client.pipeline();
pipeline.hget(`shorturl:${code}`, 'url');
pipeline.incr(`shorturl:${code}:clicks`);
pipeline.zadd(`shorturl:${code}:clicklog`, now, clickId);
const results = await pipeline.exec();
return results[0][1] as string | null;
}
async function getClickStats(
client: Redis,
code: string,
start: number = 0,
end: number = Date.now() / 1000
): Promise<{ total: number; inRange: number }> {
const total = await client.get(`shorturl:${code}:clicks`) || '0';
const inRange = await client.zcount(`shorturl:${code}:clicklog`, start, end);
return { total: parseInt(total), inRange };
}
func createShortURLWithMetadata(ctx context.Context, client *redis.Client, url string) (string, error) {
urlID, err := client.Incr(ctx, "shorturl:counter").Result()
if err != nil {
return "", err
}
code := encodeBase62(urlID)
// Store URL and creation time
err = client.HSet(ctx, "shorturl:"+code, map[string]interface{}{
"url": url,
"created_at": time.Now().Unix(),
}).Err()
return code, err
}
func getURLAndTrack(ctx context.Context, client *redis.Client, code string) (string, error) {
now := float64(time.Now().UnixNano()) / 1e9
clickID := fmt.Sprintf("%f:%s", now, uuid.New().String()[:8])
// Use pipeline to batch commands
pipe := client.Pipeline()
urlCmd := pipe.HGet(ctx, "shorturl:"+code, "url")
pipe.Incr(ctx, "shorturl:"+code+":clicks")
pipe.ZAdd(ctx, "shorturl:"+code+":clicklog", redis.Z{Score: now, Member: clickID})
_, err := pipe.Exec(ctx)
if err != nil && err != redis.Nil {
return "", err
}
return urlCmd.Val(), nil
}
func getClickStats(ctx context.Context, client *redis.Client, code string, start, end float64) (map[string]int64, error) {
total, _ := client.Get(ctx, "shorturl:"+code+":clicks").Int64()
inRange, _ := client.ZCount(ctx, "shorturl:"+code+":clicklog",
fmt.Sprintf("%f", start), fmt.Sprintf("%f", end)).Result()
return map[string]int64{"total": total, "in_range": inRange}, nil
}
Choosing an approach
Use basic shortening when you only need redirects and do not care about analytics. It is the simplest possible implementation: one key per short code, one GET per redirect. Memory usage is minimal and performance is optimal.
Use click tracking when you need to know how your links perform. The additional writes add negligible latency when pipelined, and the sorted set gives you flexible time-range queries. Consider setting a TTL on the click log to prevent unbounded growth, or periodically aggregate old data into daily/weekly counters.
Both approaches scale well. Redis handles millions of keys without issue, and the operations are O(1) for basic lookups or O(log N) for sorted set operations. For extremely high traffic links, consider using Redis Cluster to distribute the load, or add a caching layer in front of Redis for the most popular short codes.