Skip to content
HTTP Content Negotiation: A Developer's Guide

HTTP Content Negotiation: A Developer's Guide

DodaTech Updated Jun 20, 2026 7 min read

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

Prerequisites: HTTP Protocol fundamentals, REST vs GraphQL basics.

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.0

Accept-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

AspectServer-DrivenAgent-Driven
HowServer chooses based on request headersServer returns 300 Multiple Choices
HeadersAccept, Accept-Language, Accept-Encoding
Round trips12 (client then picks)
ControlServer decidesClient decides
CachingComplex (Vary header needed)Simpler
Used byMost REST APIs, web serversRare; 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 v1

Common 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

Can content negotiation work with query parameters?
Yes. Many APIs use ?format=json or ?format=xml as an alternative to Accept headers. This is called “agnostic negotiation” and is simpler for clients.
What's the default Accept header for browsers?
Browsers typically send Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8. They prefer HTML but accept other formats.
How do I negotiate API versions?
Use vendor-specific media types: application/vnd.myapi.v1+json for version 1, application/vnd.myapi.v2+json for version 2.
Is content negotiation only for REST APIs?
No. It’s used everywhere in HTTP — browsers negotiate language, encoding, and content type. GraphQL uses it for response format negotiation too.

Try It Yourself

▶ Try It Yourself Edit the code and click Run

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