ORM CRUD Basics
With the Project, Tag, and Task tables created in the
previous lesson, it's time to actually put data in them and get it back out. Django's
ORM — Object-Relational Mapper — is the layer that lets you do this entirely in
Python: no raw SQL strings, no manually escaping values, no hand-written INSERT or
UPDATE statements for the vast majority of everyday work. This lesson walks through
the four operations every data-backed app needs — Create, Read, Update, Delete — using the
Task model as the running example.
What the ORM Actually Buys You
Without an ORM, every one of these operations would mean writing SQL by hand — building
INSERT statements with the right placeholders, escaping values to avoid SQL
injection, parsing rows back out of a cursor into something usable. The Django ORM handles all
of that for you, and it does one more thing that matters just as much: it makes your code
portable. The exact same Task.objects.filter(...) call works
whether the project's database is SQLite (great for local development), PostgreSQL, or MySQL —
because Django translates your Python calls into whichever SQL dialect the configured database
actually speaks.
Create: Two Equivalent Ways
Django gives you two ways to create a new row, and both end up doing the same thing under the
hood — inserting a new record into the taskflow_task table.
Option 1 — the one-line shortcut, objects.create():
# Assume `my_project` is an existing Project instance
task = Task.objects.create(
title='Write quarterly report',
project=my_project,
priority=3,
)
Option 2 — the explicit two-step version:
task = Task(
title='Write quarterly report',
project=my_project,
priority=3,
)
task.save()
Both leave you with a saved Task row and a Python object referencing it. Reach for
objects.create() when you have every field value ready upfront and just want the
row saved in one step. Reach for the explicit Task(...) then .save()
form when you need to do something with the object before saving it — for example,
setting a computed field, running validation logic, or conditionally deciding not to save at
all.
Read: all(), get(), and the Exception Trap
Reading data back comes in two everyday flavors. Task.objects.all() returns every
row in the table:
all_tasks = Task.objects.all()
for task in all_tasks:
print(task.title)
Task.objects.get(...) returns exactly one row, matched by whatever lookup you pass
in — commonly the primary key:
task = Task.objects.get(id=5)
print(task.title)
.get() has a behavior that catches beginners off guard: it raises an
exception if it doesn't find exactly one matching row — Task.DoesNotExist
if there are zero matches, or Task.MultipleObjectsReturned if there's more than
one. This is different from .filter(), which never raises for zero or multiple
matches — it just returns an empty or multi-item QuerySet either way. That distinction matters
enough that the next lesson, QuerySets & Filtering,
is built entirely around .filter() and how to query safely with it. The practical
rule: use .get() only when you're confident exactly one row can match — a primary
key lookup is the classic case — and wrap it in a try/except if there's any doubt.
try:
task = Task.objects.get(id=999)
except Task.DoesNotExist:
print('No task with that id.')
Update: Fetch, Change, Save
Updating a single row follows a simple, three-step pattern: fetch the object, change one or
more attributes on it in Python, then call .save() to write those changes back.
task = Task.objects.get(id=5)
task.completed = True
task.priority = 1
task.save()
That single .save() call writes both changed fields back in one UPDATE
statement, no matter how many attributes you changed beforehand.
Bulk Updates with .update()
Fetching an object into Python just to change one field is wasteful when you're updating many
rows at once. For that case, calling .update() directly on a QuerySet updates every
matching row in a single database query, without ever loading the individual objects into
Python.
# Mark every overdue, incomplete task as high priority — no objects fetched
Task.objects.filter(completed=False).update(priority=3)
This is meaningfully faster than looping over a QuerySet and calling .save() on
each object individually, because it's one SQL statement instead of one per row. The tradeoff:
.update() skips each model instance's .save() method entirely (and any
custom logic inside it), so use it when you specifically want a fast, bulk field change rather
than per-object behavior.
Delete: Single and Bulk
Deleting a single object you already have a reference to is a one-line call:
task = Task.objects.get(id=5)
task.delete()
Deleting many rows at once works the same way .update() does — call
.delete() directly on a filtered QuerySet, and every matching row is removed in one
operation:
# Clear out every completed task in one call
Task.objects.filter(completed=True).delete()
.filter() call's results with .count() before chaining .delete() onto it in anything touching real data.Full Worked Example: CRUD in Sequence
Putting all four operations together against the TaskFlow Task model:
# CREATE
task = Task.objects.create(
title='Prepare client demo',
project=my_project,
priority=2,
)
# READ
task = Task.objects.get(id=task.id)
print(task.title, task.completed)
# UPDATE
task.completed = True
task.save()
# DELETE (once it's no longer needed)
task.delete()
Four operations, four lines of intent, zero SQL written by hand. This pattern — create, read back, update, and eventually delete — is the backbone of nearly every view you'll write once TaskFlow has real forms and users attached to it.
.get() and .filter() only scratch the surface of querying. QuerySets & Filtering covers lazy evaluation, field lookups, ordering, and chaining — the tools you'll use to ask real questions of your data.