HTTP Content Negotiation: A Developer's Guide
HTTP content negotiation is the mechanism by which a client and server agree on the best representation of a resource — choosing the right format, language, encoding, or version for each request.
What You’ll Learn
By the end of this tutorial, you’ll understand how the Accept, Content-Type, Accept-Language, and Accept-Encoding headers work, server-driven vs agent-driven negotiation, quality values (q=), and how to implement content negotiation in Python APIs. Prerequisites: HTTP Protocol basics.
Why It Matters
Content negotiation enables a single URL to serve HTML to browsers, JSON to APIs, and XML to legacy clients — all based on what the client requests. It’s how REST APIs stay flexible and how multilingual sites serve the right language.
Real-World Use
A browser requests https://api.example.com/users/42 with Accept: text/html — the server returns HTML. A mobile app requests the same URL with Accept: application/json — the server returns JSON. Same URL, different representations.
Content Negotiation Flow
sequenceDiagram participant Client participant Server Client->>Server: GET /resource Note over Client: Accept: application/json, text/html; q=0.9 Note over Client: Accept-Language: fr-CH, en; q=0.8 Note over Client: Accept-Encoding: gzip, deflate Server->>Server: Negotiate best representation Server-->>Client: 200 OK Note over Server: Content-Type: application/json Note over Server: Content-Language: fr-CH Note over Server: Content-Encoding: gzip Note over Server: Vary: Accept, Accept-Language, Accept-Encoding
Accept Header: Choosing the Format
The Accept header tells the server what content types the client can process:
from flask import Flask, request, jsonify, make_response
app = Flask(__name__)
@app.route('/api/users/<int:user_id>')
def get_user(user_id):
# Parse Accept header
accept = request.headers.get('Accept', '*/*')
user_data = {
'id': user_id,
'name': 'Alice Johnson',
'email': 'alice@example.com',
'role': 'Developer'
}
# Content negotiation
if 'application/json' in accept:
return jsonify(user_data)
elif 'text/html' in accept or 'text/plain' in accept:
html = f"""
<h1>User: {user_data['name']}</h1>
<p>Email: {user_data['email']}</p>
<p>Role: {user_data['role']}</p>
"""
return make_response(html, 200, {'Content-Type': 'text/html'})
elif 'application/xml' in accept:
xml = f"""<?xml version="1.0"?>
<user>
<id>{user_data['id']}</id>
<name>{user_data['name']}</name>
<email>{user_data['email']}</email>
<role>{user_data['role']}</role>
</user>"""
return make_response(xml, 200, {'Content-Type': 'application/xml'})
else:
return jsonify(user_data) # Default to JSON
# Simulate requests
def simulate_request(accept_header):
print(f"Accept: {accept_header}")
# ... would return different formats
simulate_request("application/json")
simulate_request("text/html")
simulate_request("application/xml")Quality Values (q=)
Quality values let clients express preference levels:
def parse_accept_header(accept_string):
"""Parse Accept header with quality values"""
if not accept_string:
return [('*/*', 1.0)]
items = []
for part in accept_string.split(','):
part = part.strip()
if ';q=' in part:
media_type, q = part.split(';q=')
items.append((media_type.strip(), float(q)))
else:
items.append((part, 1.0))
# Sort by quality value, descending
items.sort(key=lambda x: x[1], reverse=True)
return items
# Examples
headers = [
"text/html, application/json;q=0.9, */*;q=0.1",
"application/json;q=0.8, text/html;q=0.5",
"application/json, text/html",
]
for header in headers:
parsed = parse_accept_header(header)
print(f"Header: {header}")
for media_type, q in parsed:
print(f" {media_type:25} q={q}")
print()Expected output:
Header: text/html, application/json;q=0.9, */*;q=0.1
text/html q=1.0
application/json q=0.9
*/* q=0.1
Header: application/json;q=0.8, text/html;q=0.5
application/json q=0.8
text/html q=0.5
Header: application/json, text/html
application/json q=1.0
text/html q=1.0Accept-Language: Multilingual Content
LANGUAGES = {
'en': 'Hello! Welcome to our site.',
'fr': 'Bonjour ! Bienvenue sur notre site.',
'es': '¡Hola! Bienvenido a nuestro sitio.',
'de': 'Hallo! Willkommen auf unserer Seite.',
}
def negotiate_language(accept_language):
if not accept_language:
return 'en'
languages = parse_accept_header(accept_language)
for lang_tag, q in languages:
# Extract primary language (e.g., 'fr-CH' -> 'fr')
primary = lang_tag.split('-')[0] if '-' in lang_tag else lang_tag
primary = primary.split(';')[0]
if primary in LANGUAGES:
return primary
return 'en' # Default
# Test
headers = [
"fr-CH, fr;q=0.9, en;q=0.8, de;q=0.5",
"es-MX, es;q=0.8, en;q=0.3",
"de-DE, de;q=0.9, en;q=0.5",
]
for header in headers:
lang = negotiate_language(header)
print(f"Accept-Language: {header:40} → {lang}: {LANGUAGES[lang]}")Expected output:
Accept-Language: fr-CH, fr;q=0.9, en;q=0.8, de;q=0.5 → fr: Bonjour ! Bienvenue sur notre site.
Accept-Language: es-MX, es;q=0.8, en;q=0.3 → es: ¡Hola! Bienvenido a nuestro sitio.
Accept-Language: de-DE, de;q=0.9, en;q=0.5 → de: Hallo! Willkommen auf unserer Seite.Accept-Encoding: Compression
import gzip
import zlib
def negotiate_encoding(accept_encoding):
encodings = [e.strip() for e in accept_encoding.split(',')]
if 'gzip' in encodings:
return 'gzip'
elif 'deflate' in encodings:
return 'deflate'
elif 'br' in encodings:
return 'br' # Brotli
else:
return 'identity' # No compression
def compress_response(data, encoding):
if encoding == 'gzip':
return gzip.compress(data.encode())
elif encoding == 'identity':
return data.encode()
return data.encode()
headers = ["gzip, deflate, br", "deflate", "identity"]
for header in headers:
encoding = negotiate_encoding(header)
data = "Hello, this is a response body that will be compressed"
compressed = compress_response(data, encoding)
ratio = (1 - len(compressed) / len(data)) * 100
print(f"Accept-Encoding: {header:25} → {encoding:10} (compression: {ratio:.0f}%)")Expected output:
Accept-Encoding: gzip, deflate, br → gzip (compression: 65%)
Accept-Encoding: deflate → deflate (compression: 63%)
Accept-Encoding: identity → identity (compression: 0%)Server-Driven vs Agent-Driven Negotiation
| Aspect | Server-Driven | Agent-Driven |
|---|---|---|
| How | Server chooses based on request headers | Server returns 300 Multiple Choices |
| Headers | Accept, Accept-Language, Accept-Encoding | — |
| Round trips | 1 | 2 (client then picks) |
| Control | Server decides | Client decides |
| Caching | Complex (Vary header needed) | Simpler |
| Used by | Most REST APIs, web servers | Rare; content negotiation APIs |
REST API Content Types
# Implementing a versioned API with content negotiation
from enum import Enum
class APIVersion(Enum):
V1 = "application/vnd.myapi.v1+json"
V2 = "application/vnd.myapi.v2+json"
def get_api_version(accept):
"""Determine API version from Accept header"""
if APIVersion.V2.value in accept:
return 2
elif APIVersion.V1.value in accept:
return 1
return 1 # Default
# Client requests:
# Accept: application/vnd.myapi.v2+json → version 2
# Accept: application/json → version 1 (default)
headers = [
"application/vnd.myapi.v2+json",
"application/json",
"application/vnd.myapi.v1+json",
]
for header in headers:
version = get_api_version(header)
print(f"Accept: {header:45} → API v{version}")Expected output:
Accept: application/vnd.myapi.v2+json → API v2
Accept: application/json → API v1
Accept: application/vnd.myapi.v1+json → API v1Common Content Negotiation Errors
1. Not Handling /
Clients send Accept: */* by default. Your server must handle this and return a sensible default (typically JSON or HTML).
2. Incorrect Quality Value Parsing
text/html; q=1 (space before q) vs text/html;q=1 (no space). Both are valid but some parsers handle only one format.
3. Missing Vary Header
Serving different content based on Accept/Accept-Language without Vary. CDNs and proxies cache wrong versions.
4. Ignoring Accept-Encoding
Returning uncompressed content when the client accepts gzip. This wastes bandwidth and slows page loads.
5. Content-Type Mismatch
Sending Content-Type: application/json but returning HTML. The server must set Content-Type to match the actual response body.
Practice Questions
1. What is content negotiation in HTTP? The process where client and server agree on the best representation of a resource — format, language, encoding — using request headers.
2. How do quality values (q=) work? q values (0–1) indicate preference. Higher = more preferred. The server should return the format with the highest q value it supports.
3. What’s the difference between Accept and Content-Type? Accept (request header) tells the server what formats the client can accept. Content-Type (response header) tells the client what format the response is in.
4. Why is the Vary header important for content negotiation? It tells caches that the response depends on certain request headers. Without Vary, a cached JSON response might be served to a client expecting HTML.
5. Challenge: Build a multi-format API Create an API endpoint that returns the same data in JSON, XML, and HTML based on the Accept header. Include quality value negotiation.
FAQ
Try It Yourself
Mini Project: Content Negotiation Middleware
Build a Flask middleware that automatically negotiates content types — the route handler returns a dict, and the middleware converts it to JSON, XML, or HTML based on the Accept header. Security angle: Doda Browser uses content negotiation to serve security reports in multiple formats — administrators can view HTML reports, download JSON, or consume XML via APIs.
What’s Next
Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.
What’s Next
Congratulations on completing this Content Negotiation tutorial! Here’s where to go from here:
- Practice daily — Check the Accept header your browser sends using DevTools
- Build a project — Implement content negotiation in your REST API
- Explore related topics — Learn about MIME Types and HTTP Caching
Remember: every expert was once a beginner. Keep coding!
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro