Collection and batch fields¶
Collection is serializer-driven: a BatchSerializer declares which of its
fields are batch fields, and before any row is validated the list serializer
- extracts and coerces each row's key for every batch field (the value the row sends, or a derived or stored key),
- fetches one map per field for the whole batch (
{key: value}), - validates every row against those maps, with no further I/O.
The maps are available as self.collected in the serializer (and as
context["collected"]), keyed by the field's map name.
A single object is collected the same way, as a one-row batch. That covers
the usual ModelViewSet create and update routes and a plain
OrderSerializer(data=row).is_valid(): one lookup per batch field, with the
same key coercion, derived and stored keys (self.instance is the update
target), depends_on order and the view's batch_collector_class. Maps you
put in context["collected"] yourself are used as they are.
Learning by example
The drf-batch cookbook shows each option below in a runnable scenario, with the batches it accepts and rejects.
BatchRelatedField¶
A relation resolved from the database. A hit returns the model instance,
so create() receives an FK instance and your validate() can inspect it
(attrs["ticker"].isin) without a query.
| Parameter | Default | Meaning |
|---|---|---|
lookup |
"pk" |
field on the related model that row values match |
queryset |
all rows | rows the lookup may resolve to (scoping) |
model |
the same-named FK's model | explicit model for non-FK lookups (or queryset.model) |
map |
the field name | key in collected (two fields may share one map) |
error |
"Object '{value}' not found." |
miss message; {value} is the coerced key |
depends_on |
() |
batch fields whose fetches must finish first (concurrency) |
derived_from |
() |
field name(s) the key is built from when the row does not send it (keys) |
key |
None |
callable building the key from the derived_from values |
read_only |
False |
the row's value is ignored; excluded from validated_data |
The lookup model is derived from the ForeignKey (or OneToOneField) on
Meta.model named by the field's source. A non-FK lookup needs model= or
queryset=; anything else is an ImproperlyConfigured when the serializer is
built.
Scoping lookups¶
Like DRF's RelatedField, restrict the rows a client may reference with
queryset=, or per request with get_queryset():
class TenantTickerField(BatchRelatedField):
def get_queryset(self):
return Ticker.objects.filter(tenant=self.context["request"].user.tenant)
A key outside the queryset is an ordinary miss (400 for that row).
Key coercion¶
Row values are coerced to the lookup field's type before collection, so
1101 and "1101" resolve the same CharField lookup. A value that cannot
be coerced is a 400 row error raised before anything is fetched, never a
500.
| Lookup field | Accepted | Rejected (expected a …) |
|---|---|---|
CharField, TextField (and subclasses) |
strings, integers, integral floats | lists, dicts, booleans, … |
| integer and auto fields | integers, integral floats, numeric strings within the column's range | other strings, out-of-range numbers, booleans |
FloatField, DecimalField |
numbers, numeric strings | other strings, booleans |
UUIDField |
UUIDs, UUID strings | anything else |
BooleanField |
booleans | anything else |
| dates, times, other types | passed through unchanged | — |
The read path¶
BatchRelatedField renders the lookup value ("1101"), so the same
serializer works for list and retrieve. Use select_related in
get_queryset() for those, as with any related field.
BatchServiceField¶
A map fetched by your own callable, sync or async def:
def fetch_bands(keys, collected, context):
return {key: band_service.lookup(key) for key in keys}
band = BatchServiceField(fetch=fetch_bands, read_only=True)
fetch(keys, collected, context)receives the coerced key set for this field, a snapshot of the maps collected so far (always including everydepends_onmap), and the serializer context, and returns a{key: value}dict.- Unlike a database lookup, the fetch runs even when no row supplied a key,
because its map may be derived from the other maps. Return
{}when nothing applies. prepare_keyis the identity. Subclass and overrideprepare_key(value)to coerce service keys. It must be idempotent, mapNonetodrf_batch.fields.NO_KEY("no key"), and raiseValidationErrorfor a malformed value.- Subclass
BatchServiceField(orBatchField) and overridefetchinstead of passingfetch=.
Combining service and model fields¶
Not every lookup is a database row. BatchServiceField fetches its map with
your own callable (sync or async def), and depends_on orders fetches so a
service fetch can consume database results — and a database fetch can consume
service results. Both directions, no collector class:
# quickstart_app/serializers.py
from drf_batch import BatchSerializer, BatchRelatedField, BatchServiceField
from rest_framework import serializers
from .models import Order, Ticker
# A mocked third-party "exchange listing" service — in a real app the fetch
# would be an HTTP/gRPC call; a dict plays the external system.
_LISTINGS = {"1101": "A", "1102": "B"} # ticker ISIN -> band
def fetch_bands(keys, collected, context):
# Service-after-DB: {isin: band}, only for the tickers the ticker query
# found.
tickers = collected.get("ticker", {})
return {
isin: _LISTINGS[isin] for isin in keys if isin in tickers and isin in _LISTINGS
}
class ExchangeScopedTickerField(BatchRelatedField):
# DB-after-service: only fetch tickers the service listed on exchange A.
def fetch(self, keys, collected, context):
bands = collected.get("band", {})
allowed = {isin for isin, band in bands.items() if band == "A"}
return Ticker.objects.filter(isin__in=(keys & allowed)).in_bulk("isin")
class ServiceOrderSerializer(BatchSerializer):
# Fetch order (via depends_on):
# 1. ticker, customer -> DB, concurrently (no dependencies)
# 2. band -> service, as soon as ticker is done
# 3. exchange_ticker -> DB again, scoped to the service's band "A"
# band and exchange_ticker are read-only and keyed by the row's ticker
# (derived_from): the one it sends, or the stored one on a PATCH.
ticker = BatchRelatedField(lookup="isin", error="Ticker '{value}' not found.")
customer = BatchRelatedField(lookup="isin", error="Customer '{value}' not found.")
band = BatchServiceField(
fetch=fetch_bands,
derived_from="ticker",
depends_on=("ticker",),
read_only=True,
)
exchange_ticker = ExchangeScopedTickerField(
model=Ticker,
lookup="isin",
derived_from="ticker",
depends_on=("band",),
read_only=True,
)
volume = serializers.IntegerField(min_value=1)
class Meta:
model = Order
fields = ("id", "ticker", "customer", "band", "exchange_ticker", "volume")
def validate(self, attrs):
# Rules that consume the service results (zero I/O). self.fetched()
# works on a PATCH that omits ticker too: attrs["ticker"] would not.
if self.fetched("band") == "B":
raise serializers.ValidationError(
{"ticker": ["Ticker is suspended on exchange B."]}
)
if self.fetched("exchange_ticker") is None:
raise serializers.ValidationError(
{"exchange_ticker": ["Ticker is not listed on exchange A."]}
)
return attrs
curl -X POST http://localhost:8000/service-orders/batch/ \
-H 'Content-Type: application/json' \
-d '[
{"ticker": "1101", "customer": "112001", "volume": 3}
]'
fetch(keys, collected, context)receives the coerced key set for this field, the maps of itsdepends_on(and anything else already done), and the serializer context; return a{key: value}dict. A service fetch can therefore build its map from the database results (collected["ticker"]), and a (subclassed) database fetch can scope its query to the service results.- A cycle (or an unknown
depends_onname) is anImproperlyConfiguredat serializer construction. - Read-only batch fields never read the row and are excluded from
validated_data(so bulk create is unaffected). Their keys come fromderived_fromhere; a key the row did not send is never a "not found" error, sovalidate()checksself.fetched(...)itself.
Where a key comes from¶
Each row gets a key for every batch field, in this order:
| Field | Row sends it | Row omits it, POST |
Row omits it, PATCH / PUT |
|---|---|---|---|
| writable | the row's value (null: no key) |
derived_from, else no key |
derived_from, else the stored value |
read_only |
ignored | derived_from, else no key |
derived_from, else the stored value |
DELETE rows are bare pks: nothing is collected.
The stored value is read from the update target, from the model field the
batch field maps to (its source):
- a column: its value (
volume = BatchServiceField(...)on aPATCHwithoutvolumefetches the storedvolume); - a ForeignKey / OneToOneField on a
BatchServiceField: the FK column (customer_id); - a
BatchRelatedField: the stored object's lookup value. Apklookup reads the FK column; any other (isin) is read across the relation, which the target query joins (one query, no N+1); - not a model field: no key.
A service field therefore sees a key for every row on PATCH, with no
opt-in. A writable BatchRelatedField the row omits only adds its stored
key when another batch field reads it (derived_from or depends_on), so a
plain PATCH costs no extra query.
derived_from builds the key from other fields, for a row that does not
send the field (always, for read_only):
class OrderSerializer(BatchSerializer):
ticker = BatchRelatedField(lookup="isin")
volume = serializers.IntegerField(min_value=1)
# key: the row's ticker isin (sent, or stored on a PATCH)
band = BatchServiceField(fetch=fetch_bands, derived_from="ticker", read_only=True)
# key: (isin, volume), each sent or stored
limit = BatchServiceField(
fetch=fetch_limits, derived_from=("ticker", "volume"), read_only=True
)
# key built by a callable; return None for no key
tier = BatchServiceField(
fetch=fetch_tiers,
derived_from="volume",
key=lambda volume: None if volume is None else volume // 1000,
read_only=True,
)
- Each name is a serializer field or a model field:
- a batch field gives its own key for the row (chains are fine);
- a plain field gives the row's value converted by the field
(
to_internal_value), or the stored value on update; - a model field not on the serializer gives the stored value.
- Without
key=, one name gives its value and several names give a tuple. AnyNoneamong them means no key. derived_fromworks on keys, before any fetch, so it adds no waiting. Adddepends_ononly if the fetch reads another field's results (collected["ticker"]above).
Misses. Only a value the row sent can be a "not found" error. A derived
or stored key that the fetch does not return is not an error: read it in
validate() with self.fetched(name), which gives the field's value for
the current row (None when there is no key or no hit), and reject the row
yourself.
Configuration errors fail when the serializer is built
(ImproperlyConfigured). These are an unknown name, the field itself, a plain
DRF related field (declare it as a BatchRelatedField), a derived_from
cycle, and key= without derived_from.
Concurrency and depends_on¶
The batch fields' fetches run as one dependency graph inside an
asyncio.TaskGroup: every fetch starts as soon as its own depends_on
are done, and fetches without a dependency between them run at the same time.
async def fetches run on the event loop (which one);
sync fetches run on worker threads (or on the request thread inside a
transaction, see below).
Seven fetches, where f2 and f3 need f1, f4 needs f3, and f6 needs
f5:
class PortfolioSerializer(BatchSerializer):
f1 = BatchServiceField(fetch=fetch_1, read_only=True)
f2 = BatchServiceField(fetch=fetch_2, depends_on=("f1",), read_only=True)
f3 = BatchServiceField(fetch=fetch_3, depends_on=("f1",), read_only=True)
f4 = BatchServiceField(fetch=fetch_4, depends_on=("f3",), read_only=True)
f5 = BatchServiceField(fetch=fetch_5, read_only=True)
f6 = BatchServiceField(fetch=fetch_6, depends_on=("f5",), read_only=True)
f7 = BatchServiceField(fetch=fetch_7, read_only=True)
They run as TaskGroup(f1 → TaskGroup(f2, f3 → f4), f5 → f6, f7):
time ──────────────────────────────────────────────▶
f1 ██████
f2 ████████████
f3 ████
f4 ██████ ← starts when f3 ends; does not wait for f2
f5 ████████
f6 ██████ ← starts when f5 ends
f7 ██████████
The request waits for the longest path (f1 → f3 → f4 or f1 → f2 here),
not for the sum of all seven fetches. collected (and self.collected)
still lists the maps in plan order (f1, f2, … f7), whatever order they
finished in.
Every dependency is a wait¶
Long chains slow collection down
depends_on serializes fetches: each edge makes one fetch wait for
another, so a deep chain gives up the concurrency. Declare a dependency
only when the fetch really reads that map, and keep the graph shallow.
# A chain: four round-trips one after another (latency = a + b + c + d).
a = BatchServiceField(fetch=fetch_a, read_only=True)
b = BatchServiceField(fetch=fetch_b, depends_on=("a",), read_only=True)
c = BatchServiceField(fetch=fetch_c, depends_on=("b",), read_only=True)
d = BatchServiceField(fetch=fetch_d, depends_on=("c",), read_only=True)
# Only d really needs the others: three concurrent fetches, then one
# (latency = max(a, b, c) + d).
a = BatchServiceField(fetch=fetch_a, read_only=True)
b = BatchServiceField(fetch=fetch_b, read_only=True)
c = BatchServiceField(fetch=fetch_c, read_only=True)
d = BatchServiceField(fetch=fetch_d, depends_on=("a", "b", "c"), read_only=True)
What a fetch sees in collected¶
A fetch receives a snapshot of the maps finished when it starts. It
always holds its depends_on maps (and update/delete targets); a map that
is not a declared dependency may or may not be there yet, so read only what
you declared:
def fetch_band(keys, collected, context):
tickers = collected["ticker"] # guaranteed: declared below
# collected.get("customer") may be missing: it is not a dependency
return {isin: band_of(ticker) for isin, ticker in tickers.items()}
band = BatchServiceField(fetch=fetch_band, depends_on=("ticker",), read_only=True)
Writing to the snapshot has no effect on the result; return the map instead.
Transactions¶
Worker threads use their own database connections, which cannot see a
transaction's uncommitted writes. So when the request's connection is in a
transaction (ATOMIC_REQUESTS = True, an outer atomic(), Django's
TestCase), or a database is a private in-memory SQLite database, sync
fetches run on the request thread, one at a time, while it waits for the
graph. They see the transaction; async def fetches
stay concurrent. Details: Database connections.
Collection only reads, but those reads must see what the request's transaction sees. A worker thread's connection is a separate transaction, so it would:
- miss rows written earlier in the same transaction (by the view, a hook,
middleware, or a
TestCasefixture): a valid key becomes a "not found" row error; - block on rows the request locked with
select_for_update(), while the request waits for it: a deadlock; - read a different snapshot under
REPEATABLE READ/SERIALIZABLE.
One connection cannot run queries in parallel, so seeing the transaction means running sync fetches one after another.
What it costs. scripts/bench_collection.py (100 rows, N sync fetches,
each one query plus a simulated round-trip):
| Sync fetches | Round-trip | Outside a transaction | Inside |
|---|---|---|---|
| 8 | 1 ms (local, indexed DB) | 13.6 ms | 13.8 ms |
| 4 | 5 ms | 10.7 ms | 31.2 ms |
| 8 | 20 ms | 33.6 ms | 180.5 ms |
Fast database lookups cost the same either way; slow sync I/O adds up. If that matters:
- Slow services: write the fetch as
async defwith an async client (Async clients). Async fetches stay concurrent inside a transaction. - Or take the batch view out of
ATOMIC_REQUESTS, when nothing in the request writes or locks before collection. Decorating an action method has no effect; markdispatch, on a viewset that serves only the batch route (it applies to every action of the viewset):
from django.db import transaction
class OrderBatchViewSet(BatchMixin, GenericViewSet):
batch_serializer_class = OrderSerializer
@transaction.non_atomic_requests
def dispatch(self, *args, **kwargs):
return super().dispatch(*args, **kwargs)
The batch write stays all-or-none: it runs in its own atomic().
Turning concurrency off¶
batch_concurrent_collection = False runs the fetches one by one in plan
order: sync fetches inline on the request thread, each async def fetch
bridged to the loop on its own, all under one COLLECT_TIMEOUT. No loop is
started for an all-sync serializer.
class OrderViewSet(BatchMixin, GenericViewSet):
batch_serializer_class = OrderSerializer
batch_concurrent_collection = False
Outside a viewset, pass it as a keyword:
Full control: BatchCollector¶
Auto collection covers the common case. When the collection itself is
unusual (one query across sources, cache-first, …), set
batch_collector_class. The collector is a full-replacement hook: its
collect(keys, context) runs instead of auto collection.
# quickstart_app/collectors.py
from drf_batch import BatchCollector, BatchContext
from .models import Customer, Ticker
class OrderCollector(BatchCollector):
def collect(self, keys: dict[str, set], context: BatchContext) -> dict:
# keys are the coerced key sets derived from the serializer's batch
# fields ({"ticker": {...}, "customer": {...}}), never raw rows.
# Return EVERY map the batch fields need; a missing map means every
# row gets a miss error for it.
return {
"ticker": Ticker.objects.filter(isin__in=keys.get("ticker", set())).in_bulk(
"isin"
),
"customer": Customer.objects.filter(
isin__in=keys.get("customer", set())
).in_bulk("isin"),
}
class OrderViewSet(BatchMixin, GenericViewSet):
batch_collector_class = OrderCollector
...
Malformed keys never reach your code: they are rejected in the extraction
pass before collect runs, so a malformed batch is a 400 with zero
collection work. Update and delete targets are not the collector's job.
The pipeline resolves them from the view's queryset first and exposes them as
collected["id"] (the pk field name).