Data Operations (CRUD)¶
Use mo_crud_kit for model-aware bulk create/read/update workflows with Polars frames.
This workflow is designed for data-heavy endpoints where serializer-by-row patterns become a bottleneck. It applies directly to high-volume ingestion and update APIs.
When to use mo_crud_kit — and when not to
mo_crud_kit is designed for workflows where data arrives or is consumed in bulk: ingestion pipelines, large exports, high-volume upserts, or any operation where the dataset is large enough that row-by-row serializer overhead or bulk_create become the bottleneck. It works on Polars DataFrame and LazyFrame inputs — not on individual model instances.
For everyday operations — creating a single record, updating a user profile, returning a list of 20 results — use native Django serializers. They are simpler, easier to debug, and the right tool at that scale. Reach for mo_crud_kit when volume is the problem. The two approaches are complementary; most projects use both.
Prerequisites¶
- Models are migrated and uses UUID primary/foreign keys.
Implementation¶
mo_crud_kit provides model-aware operations for bulk create, read, and update.
It uses model metadata plus Polars validation to keep writes fast and consistent.
1. Model Frames¶
mo_crud_kit works on a model-frame mapping:
Each write operation returns:
status:ok,partial_ok, orfailvalid_model_frms: rows that passed validationinvalid_model_frms: rows with error metadata
Sample Model Frame
import polars as pl
from apps.orders.models import OrderModel
order_df = pl.DataFrame(
{
"order_id": ["f2fa1a5b7abf4f37a3f7e14725c0b211"],
"status": ["draft"],
"total_amount": [120.50],
}
)
model_frms = {
OrderModel: order_df,
}
2. Create¶
Create rows from model-to-frame mappings with optional validation pipeline.
Usage:
from django_mindoff import mo_crud_kit
status, valid_model_frms, invalid_model_frms = mo_crud_kit.create(
{
OrderModel: order_df,
OrderItemModel: order_item_df,
},
is_partial=False,
validation_level="full",
batch_size=1000,
)
Parameters:
model_frms(dict[type[models.Model], pl.DataFrame|pl.LazyFrame]): Input model-frame mapping for bulk insert.is_partial(bool, default=False): IfTrue, allows partial save when only a subset of rows are valid (applies tovalidation_level="full"only).validation_level("full"|"columns_only"|"none", default="full"): Selects how much validation runs before the insert.batch_size(int, default=1000): Maximum number of rows written per INSERT batch.
Varieties:
validation_level="full"(default): runsColumnValidator -> RowValidator -> ForeignKeyValidatorand honorsis_partial.validation_level="columns_only": runs onlyColumnValidatorto normalize frame shape (rename todb_column, add missing/auto columns, drop extras) and writes directly, trusting the caller's row values and foreign keys. Row-level passes are skipped, so field defaults,auto_now/auto_now_addtimestamps, UUID generation, and type coercion are NOT applied — the caller must supply database-ready values for every required column.is_partialdoes not apply (no per-row invalidation).validation_level="none": skips all validation (including column normalization) and writes the frames as-is; emits an unsafe-write warning.- Partial-save mode (full only):
is_partial=Falsefails if any invalid rows exist;is_partial=Truesaves valid rows and returns invalid rows separately.
Possible responses:
- Returns
("ok", valid_model_frms, {})when all rows are valid and inserted. - Returns
("partial_ok", valid_model_frms, invalid_model_frms)when partial mode is enabled and some rows are invalid. - Returns
("fail", valid_model_frms, invalid_model_frms)when validation fails and no write should proceed.
Notes:
- Invalid rows contain an error column (
POLARS_VALIDATOR_ERROR_COLor__error__info). validation_level="none"skips safety checks and may persist unsafe data.- Rows are written with each backend's fastest same-transaction bulk
loader — PostgreSQL
COPY, MySQLLOAD DATA LOCAL INFILE(auto-falling back to row binding when the server forbids it), SQLiteexecutemany— so the columnar frame reaches the database without a per-row Python detour, and the whole create stays atomic.
3. Read¶
Read queryset data into Polars with streaming/pagination variants.
Usage:
from django_mindoff import mo_crud_kit
frm, stats = mo_crud_kit.read(
OrderModel.objects.filter(is_active=True).values(),
page_number=1,
is_lazy=False,
batch_size=100,
)
Parameters:
qs(models.QuerySet): Queryset that must use.values()output.page_number(int|None, default=None): When provided, enables pagination mode. WhenNone, uses streaming mode.is_lazy(bool, default=False): IfTrue, returnspl.LazyFrame; otherwise returnspl.DataFrame. In streaming mode with theauto/connectorxengines this is a real larger-than-RAM scan: rows are streamed to a temporary Parquet file and the returnedLazyFramescans it lazily (the temp file is removed when the frame is garbage-collected). Theiteratorengine keeps the legacy deferred-.lazy()behavior.batch_size(int, default=0): Chunk/page size. Auto-resolved when0. Drives the lazy-scan/streaming fetch size and pagination; eager fast-path reads fetch in one transfer.with_stats(bool, default=True): WhenTrue, computestotal_count/total_pages(and the empty-set short-circuit) via extraexists()/count()queries. SetFalsefor the fastest read: those queries are skipped,total_count/total_pagesareNone, and pagination deriveshas_nextby fetching one extra row.json_column_mode("auto"|"object"|"text", default="auto"): HowJSONFieldcolumns are returned.textkeeps raw JSON text (Utf8);objectparses topl.Object;autoresolves totext.
Varieties:
- Streaming mode (
page_number=None): reads the full dataset. - Pagination mode (
page_number=<n>): reads one page and returns paging metadata. - Materialization mode: eager (
DataFrame) or lazy (LazyFrame).
Possible responses:
- Returns
(frm, stats)wherefrmisDataFrame/LazyFrameandstatsincludes:mode,batch_size,total_count,total_pages,current_page,has_next,has_previous. - Returns empty frame with zeroed stats for empty querysets.
- Raises validation error if queryset is not
.values()-based.
Note:
- Frames are normalized to the canonical model dtypes
(
_crud_kit.dtypes.DJANGO_TO_POLARS_TYPE_MAP), the same mapping the create/update validators enforce, so a read can be fed straight back intoupdate.
4. Read Batches¶
Stream a queryset as an iterator of Polars frames (memory-bounded).
Usage:
from django_mindoff import mo_crud_kit
for frm in mo_crud_kit.read_batches(
OrderModel.objects.filter(is_active=True).values(),
batch_size=10_000,
):
process(frm)
Parameters:
qs(models.QuerySet): Queryset that must use.values()output.batch_size(int, default=0): Rows per yielded frame. Auto-resolved to1000when0.json_column_mode("auto"|"object"|"text", default="auto"): JSON handling, identical toread(). Each batch is streamed from Django's cursor and normalized to the canonical model dtypes.
Possible responses:
- Returns a generator of
pl.DataFramechunks; never concatenated, so the full result set is never held in memory at once. - Raises validation error if queryset is not
.values()-based.
5. Update¶
Upsert rows from model-to-frame mappings via a staged merge.
Usage:
from django_mindoff import mo_crud_kit
status, valid_model_frms, invalid_model_frms = mo_crud_kit.update(
{
OrderModel: order_updates_df,
},
is_partial=True,
validation_level="full",
batch_size=1000,
)
Parameters:
model_frms(dict[type[models.Model], pl.DataFrame|pl.LazyFrame]): Input model-frame mapping for bulk update/upsert.is_partial(bool, default=False): IfTrue, allows valid rows to proceed even when invalid rows exist (applies tovalidation_level="full"only).validation_level("full"|"columns_only"|"none", default="full"): Selects how much validation runs before the upsert. Missing DB columns are auto-fetched by primary key first (unlessskip_db_fill=True), regardless of level.batch_size(int, default=1000): Batch size used for missing-column fetch and update processing.skip_db_fill(bool, default=False): WhenTrue, skips the missing-column prefetch (the per-PKSELECTthat back-fills columns the frame omits). Use it when the caller already supplies every column to be written — e.g. a frame fromread()that was modified in place — to avoid the extra round-trip. Primary-key canonicalization and the PK presence check still run, so upsert matching is unaffected. Caution: any column the frame omits is NOT back-filled and will be written asNULL, so only enable this when the frame is column-complete.
Varieties:
validation_level="full"(default): runsColumnValidator -> RowValidator -> ForeignKeyValidatorand honorsis_partial.validation_level="columns_only": runs onlyColumnValidator(after the missing-column fetch) and upserts directly, trusting the caller's row values and foreign keys. Row-level passes are skipped, so the caller must supply database-ready values.validation_level="none": skips all validation; upserts as-is and emits an unsafe-write warning.
Possible responses:
- Returns
("ok", valid_model_frms, {})when all rows are valid and updated. - Returns
("partial_ok", valid_model_frms, invalid_model_frms)when partial mode is enabled and some rows are invalid. - Returns
("fail", valid_model_frms, invalid_model_frms)when validation blocks update.
Notes:
- Missing DB columns are auto-fetched using primary key before validation
unless
skip_db_fill=True. - Invalid rows include model-aware error details in error column.
- The staging merge loads the staging table with the same fast
same-transaction bulk loader as
create()(PostgreSQLCOPY, MySQLLOAD DATA LOCAL INFILE, SQLiteexecutemany) and then merges set-based in SQL, so no per-row Python materialization occurs.
Example Usage¶
from django_mindoff import mo_crud_kit
from apps.orders.models import OrderModel
# CREATE (full validation, written in batch_size chunks via the backend's fast
# bulk loader: PostgreSQL COPY / MySQL LOAD DATA / SQLite executemany)
create_status, create_valid, create_invalid = mo_crud_kit.create(
{OrderModel: order_df},
validation_level="full", # "full" | "columns_only" | "none"
is_partial=False,
batch_size=1000,
)
# CREATE (fast path: caller guarantees clean, DB-ready rows — skip row+FK passes)
mo_crud_kit.create(
{OrderModel: order_df},
validation_level="columns_only",
)
# READ (queryset must use values())
orders_frm, stats = mo_crud_kit.read(
OrderModel.objects.filter(is_active=True).values(),
page_number=1,
batch_size=100,
with_stats=False, # skip exists()/count() for the fastest read
)
# LARGER-THAN-RAM: a real lazy scan (streamed to disk, scanned lazily)
lazy_frm, _ = mo_crud_kit.read(
OrderModel.objects.all().values(), is_lazy=True
)
# LARGER-THAN-RAM: process in memory-bounded chunks (never concatenated)
for chunk in mo_crud_kit.read_batches(
OrderModel.objects.all().values(), batch_size=10_000
):
handle(chunk)
# UPDATE (staged merge: bulk-load staging table, then set-based SQL merge)
update_status, update_valid, update_invalid = mo_crud_kit.update(
{OrderModel: order_df},
validation_level="full", # "full" | "columns_only" | "none"
is_partial=True,
# skip_db_fill=True, # skip the missing-column prefetch when the frame
# # already has every column (e.g. a full read() frame)
)
Core Concepts¶
1. Polars Serialization¶
mo_crud_kit uses model metadata plus Polars validators (ColumnValidator, RowValidator, ForeignKeyValidator) to sanitize and validate rows before DB writes.
This is the intended replacement for serializer-driven bulk validation in data-heavy pipelines.
What this gives you:
- Type normalization aligned with Django field definitions.
- Constraint checks (required/nullability, choices, min/max, length, FK consistency).
- Structured invalid-row capture in the configured error column (
POLARS_VALIDATOR_ERROR_COL, default__error__info).
Validate before write
mo_crud_kit is built for validated tabular data. If you choose to skip the inbuilt validation + serialization, cover request-level validation in API code before sending it to CRUD Kit.
2. Limitations¶
ManyToManyFieldis not supported in row validation.BinaryFieldis not supported in row validation.- Any Django field not mapped in row validator dtype map is unsupported.
- Models must use UUID primary keys for CRUD validation flow.
- Primary key and foreign key fields are expected to define explicit
db_column. mo_crud_kit.read()requires queryset.values()input.mo_crud_kit.delete()is not currently exposed.
Troubleshooting¶
read()fails with shape/type errors
Confirm queryset input uses.values()and field names align with frame columns.- Rows are silently excluded from writes
Inspectinvalid_model_frmsand the configured error column to trace validation failures. - FK validation fails unexpectedly
Check UUID types and explicitdb_columnconfiguration on related fields.