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()
Note: Bulk deletes are immediate and permanent from Django's side — there's no confirmation step. Always double-check a .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.

Note: This is correct, real Django ORM code — but this website's own project doesn't run a Django server, so you won't see these operations actually execute against a database on this page. Try them in your own project's shell to see the results firsthand.
Next up: .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.