orjson Is Stupid Fast
The Python standard library json module is fine. But if you're building an API, you should probably use orjson instead.
How much faster?
Like, 10x faster. On big payloads, even more.
I ran a quick benchmark serializing a 1MB JSON document:
- json.dumps: 150ms
- orjson.dumps: 15ms
If you're handling thousands of requests, that adds up fast. I switched this portfolio's API endpoints to orjson and the difference was noticeable even on my tiny payloads. It just feels snappier.
Real-world benchmark methodology
Here's how I actually benchmark JSON libraries in production-like scenarios. Don't just trust the numbers people throw around - measure what matters for your use case.
import timeit
import json
import orjson
from datetime import datetime
from uuid import uuid4
# Generate realistic test data
def generate_test_data(num_records=1000):
return [
{
'id': str(uuid4()),
'user_id': i,
'username': f'user_{i}',
'email': f'user{i}@example.com',
'created_at': datetime.now().isoformat(),
'metadata': {
'last_login': datetime.now().isoformat(),
'preferences': {
'theme': 'dark',
'notifications': True,
'language': 'en'
},
'stats': {
'posts': i * 10,
'followers': i * 5,
'following': i * 3
}
}
}
for i in range(num_records)
]
data = generate_test_data()
# Benchmark serialization
json_time = timeit.timeit(lambda: json.dumps(data), number=100)
orjson_time = timeit.timeit(lambda: orjson.dumps(data), number=100)
print(f"stdlib json: {json_time:.3f}s")
print(f"orjson: {orjson_time:.3f}s")
print(f"Speedup: {json_time / orjson_time:.1f}x")
On my MacBook Pro with 1000 records, orjson is consistently 8-12x faster. But the real win shows up when you measure p95 and p99 latencies under load, not just average times.
When does speed actually matter?
For a CRUD app serving a few hundred requests per day? Honestly, stdlib json is probably fine. But if you're building a high-throughput API - analytics dashboards, real-time data feeds, microservices handling thousands of requests per second - the performance difference becomes critical.
Say you're building an API that returns analytics data. Each response is ~500KB of JSON. At 1000 requests/second, json.dumps will burn ~75ms per request. orjson does it in ~7ms. That's 68ms saved per request, which translates to handling way more concurrent requests on the same hardware. The cost savings alone can justify the switch.
I've seen production APIs cut p99 latency by 40-50ms just by switching to orjson. That's a free performance win with basically no code changes.
A real case study: analytics dashboard API
I worked on an analytics dashboard that served financial data to ~50k daily active users. Each dashboard query returned between 200KB-2MB of JSON containing time series data, aggregations, and metadata.
Before orjson:
- Average response time: 180ms
- p95 latency: 420ms
- p99 latency: 850ms
- CPU usage on API servers: ~65% average
After switching to orjson:
- Average response time: 125ms
- p95 latency: 280ms
- p99 latency: 520ms
- CPU usage on API servers: ~48% average
The 55ms average improvement doesn't sound huge until you realize it meant we could handle the same traffic with 25% fewer servers. That's real money saved every month. The p99 improvement was even more dramatic - those tail latencies dropped by over 300ms, which meant way fewer user complaints about "slow dashboards."
Why is it so fast?
It's written in Rust. Same story as ruff, uv, pydantic v2 - whenever someone rewrites a Python tool in Rust, it gets stupidly fast. There's a pattern here.
orjson also makes smart choices about memory allocation and string handling that the stdlib json module doesn't. It's been optimized specifically for the common cases you actually care about.
Plus it's just more memory efficient. The stdlib json module creates intermediate Python objects during serialization. orjson goes straight from Python objects to bytes, skipping a bunch of allocations. Less memory pressure means less time in garbage collection, which means faster overall performance.
The gotcha
orjson returns bytes, not a string:
import orjson
data = {'name': 'Ben'}
result = orjson.dumps(data) # b'{"name":"Ben"}'
For HTTP responses this is actually what you want anyway - you're sending bytes over the wire. But if you need a string for some reason:
result = orjson.dumps(data).decode('utf-8')
This bytes vs string thing trips people up initially, but it's the right design. HTTP responses are bytes. Files are bytes. Network sockets send bytes. The stdlib json module converting to a string just adds an extra allocation that you'll immediately undo when sending the response.
Common gotchas with the bytes return type
If you're using orjson with certain frameworks or libraries, you might hit some rough edges:
# This won't work - can't concatenate bytes and str
header = '{"status": "ok"}'
body = orjson.dumps(data)
response = header + body # TypeError
# Do this instead
header = b'{"status": "ok"}'
body = orjson.dumps(data)
response = header + body # Works
# Or decode to str if you really need it
response = '{"status": "ok"}' + orjson.dumps(data).decode('utf-8')
Most modern web frameworks handle bytes responses just fine, but if you're working with older code that expects strings, you'll need to call .decode('utf-8') everywhere. It's a tiny performance hit but still way faster than stdlib json.
Bonus: it handles more types
orjson can serialize datetime and UUID objects natively. No more writing custom encoders or converting everything to strings first:
from datetime import datetime
from uuid import uuid4
orjson.dumps({
'timestamp': datetime.now(),
'id': uuid4()
}) # Just works
It also handles dataclasses and Pydantic models out of the box. With the stdlib json module you'd need to write a custom encoder or convert to a dict first:
from dataclasses import dataclass
from pydantic import BaseModel
@dataclass
class User:
name: str
email: str
class UserModel(BaseModel):
name: str
email: str
# Both just work
orjson.dumps(User("Ben", "ben@example.com"))
orjson.dumps(UserModel(name="Ben", email="ben@example.com"))
This is huge if you're using Pydantic for API validation. You can validate with Pydantic, serialize with orjson, and skip the .dict() or .model_dump() step entirely.
The datetime serialization format
orjson serializes datetimes to ISO 8601 format by default, which is what you want for APIs 99% of the time:
from datetime import datetime, timezone
dt = datetime(2025, 9, 21, 14, 30, 45, tzinfo=timezone.utc)
orjson.dumps({'timestamp': dt})
# b'{"timestamp":"2025-09-21T14:30:45+00:00"}'
# With OPT_UTC_Z for the cleaner Z suffix
orjson.dumps({'timestamp': dt}, option=orjson.OPT_UTC_Z)
# b'{"timestamp":"2025-09-21T14:30:45Z"}'
Naive datetimes (without timezone info) get serialized without timezone information. This can bite you if you're not careful:
naive_dt = datetime(2025, 9, 21, 14, 30, 45)
orjson.dumps({'timestamp': naive_dt})
# b'{"timestamp":"2025-09-21T14:30:45"}' # No timezone!
Always use timezone-aware datetimes in APIs. Trust me on this. I've debugged too many timezone bugs caused by naive datetimes.
Parsing JSON is fast too
Everyone talks about orjson.dumps, but orjson.loads is also significantly faster:
import orjson
json_bytes = b'{"user": {"name": "Ben", "posts": 150}}'
data = orjson.loads(json_bytes) # 3-5x faster than json.loads
If you're building an API that both sends and receives JSON (which, let's be honest, is most APIs), you get performance wins in both directions.
The parsing performance really shines when you're dealing with webhook payloads or API responses from third-party services. If you're consuming data from Stripe, GitHub, or any other webhook-heavy service, orjson.loads will handle those payloads noticeably faster than json.loads.
Memory usage during parsing
One underrated benefit of orjson.loads: it's more memory efficient. The stdlib json module creates a bunch of intermediate objects during parsing. orjson does it in a single pass with minimal allocations.
This matters most when you're parsing large JSON documents or handling many concurrent requests. Lower memory usage means less pressure on the garbage collector, which means more consistent latencies. I've seen production services reduce memory usage by 15-20% just from switching to orjson.
The OPT_* flags explained
orjson has a bunch of useful options you can pass to dumps():
OPT_INDENT_2 - Pretty printing for debugging:
orjson.dumps(data, option=orjson.OPT_INDENT_2)
# {
# "name": "Ben"
# }
OPT_SORT_KEYS - Deterministic output for testing or caching:
orjson.dumps(data, option=orjson.OPT_SORT_KEYS)
# Keys always in same order
OPT_UTC_Z - Format datetimes with 'Z' suffix instead of '+00:00':
orjson.dumps({'time': datetime.utcnow()}, option=orjson.OPT_UTC_Z)
# {"time":"2025-09-21T10:30:00Z"}
OPT_SERIALIZE_NUMPY - Handle numpy arrays without converting to lists:
import numpy as np
orjson.dumps({'data': np.array([1, 2, 3])}, option=orjson.OPT_SERIALIZE_NUMPY)
You can combine options with the | operator:
orjson.dumps(data, option=orjson.OPT_SORT_KEYS | orjson.OPT_INDENT_2)
When to use OPT_SORT_KEYS
Sorted keys are crucial for a few specific use cases:
- Cache keys: If you're using JSON as part of a cache key, sorted keys ensure the same data always produces the same string:
def generate_cache_key(data):
json_str = orjson.dumps(data, option=orjson.OPT_SORT_KEYS).decode()
return hashlib.md5(json_str.encode()).hexdigest()
# These produce the same cache key
generate_cache_key({'a': 1, 'b': 2})
generate_cache_key({'b': 2, 'a': 1})
- Testing: Comparing JSON output in tests is way easier with deterministic key ordering:
# Without sorted keys, this test might be flaky
assert orjson.dumps(result, option=orjson.OPT_SORT_KEYS) == expected
- Diffing: If you're tracking changes to JSON documents over time, sorted keys make diffs more readable.
But don't use OPT_SORT_KEYS in production APIs unless you need it. Sorting keys adds a small performance overhead (still faster than stdlib json, but slower than unsorted orjson).
Web framework integration
FastAPI and Starlette can use orjson as their JSON serializer:
from fastapi import FastAPI
from fastapi.responses import ORJSONResponse
app = FastAPI(default_response_class=ORJSONResponse)
@app.get("/users")
async def get_users():
return {"users": [...]} # Automatically serialized with orjson
This gives you the performance benefits automatically across your entire API. No need to manually call orjson.dumps everywhere.
Flask integration
Flask requires a bit more work since it doesn't have a built-in response class for orjson:
from flask import Flask, Response
import orjson
app = Flask(__name__)
@app.route('/users')
def get_users():
data = {"users": [...]}
return Response(
orjson.dumps(data),
mimetype='application/json'
)
You can also create a custom JSON provider:
from flask.json.provider import JSONProvider
class ORJSONProvider(JSONProvider):
def dumps(self, obj, **kwargs):
return orjson.dumps(obj).decode('utf-8')
def loads(self, s, **kwargs):
return orjson.loads(s)
app = Flask(__name__)
app.json = ORJSONProvider(app)
# Now all jsonify() calls use orjson
@app.route('/users')
def get_users():
return jsonify({"users": [...]})
Django REST Framework
DRF needs a custom renderer:
from rest_framework.renderers import BaseRenderer
import orjson
class ORJSONRenderer(BaseRenderer):
media_type = 'application/json'
def render(self, data, accepted_media_type=None, renderer_context=None):
return orjson.dumps(data)
# In your settings.py
REST_FRAMEWORK = {
'DEFAULT_RENDERER_CLASSES': [
'myapp.renderers.ORJSONRenderer',
]
}
The performance improvement in DRF is especially noticeable because DRF's default JSON renderer does a lot of extra work. Switching to orjson often cuts serialization time in half.
Limitations
orjson is strict about what it can serialize. You can't just throw arbitrary Python objects at it:
class CustomClass:
pass
orjson.dumps(CustomClass()) # TypeError
For custom types, you need to implement a default handler:
def default(obj):
if isinstance(obj, CustomClass):
return obj.__dict__
raise TypeError
orjson.dumps(obj, default=default)
This is actually a feature, not a bug. It forces you to be explicit about serialization, which prevents bugs where you accidentally leak internal implementation details in your API responses.
Advanced default handlers
Here's a more comprehensive default handler that handles common edge cases:
from datetime import date, time
from decimal import Decimal
from enum import Enum
def default(obj):
# Handle dates and times
if isinstance(obj, date):
return obj.isoformat()
if isinstance(obj, time):
return obj.isoformat()
# Handle decimals (common in financial data)
if isinstance(obj, Decimal):
return float(obj)
# Handle enums
if isinstance(obj, Enum):
return obj.value
# Handle sets
if isinstance(obj, set):
return list(obj)
# Handle objects with __dict__
if hasattr(obj, '__dict__'):
return obj.__dict__
raise TypeError(f"Type {type(obj)} not serializable")
# Use it like this
orjson.dumps(data, default=default)
One gotcha: the default handler is called for every non-standard type, so keep it fast. If your default handler is slow, you're throwing away some of orjson's performance benefits.
When to stick with stdlib json
Don't optimize prematurely. Use stdlib json when:
- You're prototyping and don't care about performance yet
- Your payloads are tiny (< 1KB)
- You need human-readable output with custom formatting
- You're serializing a few times per minute, not thousands per second
The performance difference on a 500-byte JSON object is measured in microseconds. Unless you're handling serious traffic, it won't matter.
But when you do need performance, orjson is there. And the migration is so painless there's little reason not to use it for production APIs.
The migration
It's a drop-in replacement for most use cases:
# Before
import json
result = json.dumps(data)
# After
import orjson
result = orjson.dumps(data) # returns bytes now
Install it: uv add orjson. Thank me later.