Capstone: A Full CRUD App
Reviewed & published by Brayan K
A CRUD app is a web application that lets users Create, Read, Update, and Delete records — the four core operations behind nearly every site, from todo lists to blogs to online stores.
Part of the free Flask course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
In this final lesson you'll combine everything from the course — routes, Jinja templates, SQLAlchemy models, and HTML forms — into one complete task-manager application.
What You'll Build in This Capstone
- A SQLAlchemy model for a Task record
- A Create route that saves new tasks from a form
- A Read route that lists every task in a template
- An Update route that toggles a task done or not
- A Delete route that removes a task
- How all four operations fit into one Flask app
1 The Model, Create & Read
Every CRUD app starts with a model — a SQLAlchemy class describing one record. Here a Task has a title and a done flag. The home route reads all tasks and renders them; a form creates new ones.
# app.py — model, create, and read
from flask import Flask, request, redirect, url_for, render_template_string
from flask_sqlalchemy import SQLAlchemy
app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///tasks.db"
db = SQLAlchemy(app)
# --- MODEL: one row in the tasks table ---
class Task(db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
done = db.Column(db.Boolean, default=False)
# A small inline template (real apps use templates/index.html).
PAGE = """
<h1>My Tasks</h1>
<form method="post" action="/add">
<input name="title" placeholder="New task">
<button>Add</button>
</form>
<ul>
{% for task in tasks %}
<li>{{ task.title }} - {{ "done" if task.done else "open" }}</li>
{% endfor %}
</ul>
"""
# READ: list every task on the home page.
@app.route("/")
def index():
tasks = Task.query.all()
return render_template_string(PAGE, tasks=tasks)
# CREATE: a form POSTs here to add a new task.
@app.route("/add", methods=["POST"])
def add():
task = Task(title=request.form["title"])
db.session.add(task)
db.session.commit()
return redirect(url_for("index"))
with app.app_context():
db.create_all()
print("Model, create, and read routes ready. Run: flask run")
# ✅ Expected output:
# Model, create, and read routes ready. Run: flask run2 Update & Delete
The last two operations complete CRUD. Update fetches one record by its id and changes a field; Delete fetches a record and removes it. Both finish with db.session.commit() to save the change.
# app.py (continued) — update and delete
from flask import redirect, url_for
# UPDATE: toggle a task between done and open.
@app.route("/toggle/<int:task_id>")
def toggle(task_id):
task = db.get_or_404(Task, task_id) # 404 if it does not exist
task.done = not task.done
db.session.commit()
return redirect(url_for("index"))
# DELETE: remove a task entirely.
@app.route("/delete/<int:task_id>", methods=["POST"])
def delete(task_id):
task = db.get_or_404(Task, task_id)
db.session.delete(task)
db.session.commit()
return redirect(url_for("index"))
# In the template, each task would link/post to these routes:
# <a href="/toggle/{{ task.id }}">toggle</a>
# <form method="post" action="/delete/{{ task.id }}"><button>x</button></form>
print("Update and delete routes complete. CRUD is done!")Using db.get_or_404 means a request for a missing id returns a clean 404 instead of crashing. Every change is only permanent after db.session.commit().
3 Putting It All Together
Here is the complete task manager in one file — model, template, and all four CRUD routes woven together. This is the shape of a real Flask app, ready to extend with login and deploy with Gunicorn.
# app.py — the complete CRUD task manager
from flask import Flask, request, redirect, url_for, render_template_string
from flask_sqlalchemy import SQLAlchemy
app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///tasks.db"
db = SQLAlchemy(app)
class Task(db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
done = db.Column(db.Boolean, default=False)
PAGE = """
<h1>Tasks</h1>
<form method="post" action="/add">
<input name="title"><button>Add</button>
</form>
<ul>
{% for t in tasks %}
<li>
<a href="/toggle/{{ t.id }}">{{ "[x]" if t.done else "[ ]" }}</a>
{{ t.title }}
<form method="post" action="/delete/{{ t.id }}" style="display:inline">
<button>delete</button>
</form>
</li>
{% endfor %}
</ul>
"""
@app.route("/") # READ
def index():
return render_template_string(PAGE, tasks=Task.query.all())
@app.route("/add", methods=["POST"]) # CREATE
def add():
db.session.add(Task(title=request.form["title"]))
db.session.commit()
return redirect(url_for("index"))
@app.route("/toggle/<int:task_id>") # UPDATE
def toggle(task_id):
task = db.get_or_404(Task, task_id)
task.done = not task.done
db.session.commit()
return redirect(url_for("index"))
@app.route("/delete/<int:task_id>", methods=["POST"]) # DELETE
def delete(task_id):
db.session.delete(db.get_or_404(Task, task_id))
db.session.commit()
return redirect(url_for("index"))
with app.app_context():
db.create_all()
# Develop with: flask run
# Deploy with: gunicorn app:app
print("Full CRUD app assembled! Routes:",
[str(r) for r in app.url_map.iter_rules()])
# ✅ Expected output:
# Full CRUD app assembled! Routes: ['/static/<path:filename>', '/', '/add', '/toggle/<int:task_id>', '/delete/<int:task_id>']🎯 Your Turn: Fill in the Blanks
Complete the create and delete routes below. Replace each ___ so tasks are saved and removed correctly.
# 🎯 YOUR TURN: replace each ___ to finish CRUD
@app.route("/add", methods=["POST"])
def add():
task = Task(title=request.form["title"])
db.session.___(task) # stage the new task
db.session.___() # save it to the database
return redirect(url_for("index"))
@app.route("/delete/<int:task_id>", methods=["POST"])
def delete(task_id):
task = db.get_or_404(Task, task_id)
db.session.___(task) # remove the task
db.session.commit()
return redirect(url_for("index"))
print("Fill in the blanks to complete create and delete!")Common Errors & Quick Reference
❌ Changes do not appear / vanish on restart
You forgot db.session.commit(). Adding or changing objects only stages them — committing is what actually writes to the database.
❌ 405 Method Not Allowed
A form is POSTing to a route that only allows GET. Add methods=["POST"] to the route, matching the form's method.
| CRUD Operation | SQLAlchemy Code |
|---|---|
| Create | db.session.add(obj) |
| Read all | Task.query.all() |
| Read one | db.get_or_404(Task, id) |
| Update | obj.field = ...; commit() |
| Delete | db.session.delete(obj) |
🏆 Mini Challenge: Add a Priority Field
Extend the Task model so each task also stores a priority, and order the list by it.
- Add a priority integer column to the model
- Save it from the form when creating a task
- Read tasks ordered by priority
# 🏆 Mini Challenge: tasks with a priority
from flask import Flask, request, redirect, url_for
from flask_sqlalchemy import SQLAlchemy
app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///tasks.db"
db = SQLAlchemy(app)
class Task(db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
done = db.Column(db.Boolean, default=False)
priority = db.Column(db.Integer, default=1) # new field
@app.route("/add", methods=["POST"])
def add():
task = Task(
title=request.form["title"],
priority=int(request.form.get("priority", 1)),
)
db.session.add(task)
db.session.commit()
return redirect(url_for("index"))
@app.route("/")
def index():
# Read tasks ordered by priority (highest first).
tasks = Task.query.order_by(Task.priority.desc()).all()
return f"{len(tasks)} tasks, sorted by priority."
with app.app_context():
db.create_all()
print("Priority field added and tasks sorted!")❓ Frequently Asked Questions
Congratulations — you've finished the entire Flask course!
You started with a five-line "Hello, Flask!" app and finished by building a complete CRUD application that combines routes, Jinja templates, SQLAlchemy models, and forms. Along the way you added authentication, structured your code with an app factory, wrote tests, and learned to deploy with Gunicorn.
🚀 What's next? Pick a project you care about, rebuild it from scratch, add login, and deploy it to the web. You now have every tool you need — go build something real.
Practice quiz
What does CRUD stand for?
- Create, Read, Update, Delete
- Cache, Route, Update, Deploy
- Connect, Render, Use, Drop
- Create, Run, Undo, Deploy
Answer: Create, Read, Update, Delete. CRUD is Create, Read, Update, and Delete.
What is a SQLAlchemy model?
- An HTML template
- A class describing one database record
- A route decorator
- A config setting
Answer: A class describing one database record. A model is a SQLAlchemy class describing one record (one table row).
Which SQLAlchemy call stages a new object for insertion?
- db.session.commit()
- db.create(obj)
- db.session.add(obj)
- db.insert(obj)
Answer: db.session.add(obj). db.session.add(obj) stages a new object.
What actually writes staged changes to the database?
- db.session.flush_all()
- db.save()
- db.session.push()
- db.session.commit()
Answer: db.session.commit(). db.session.commit() persists the staged changes.
Which call reads all rows for the Task model?
- Task.query.all()
- Task.read_all()
- db.all(Task)
- Task.fetch()
Answer: Task.query.all(). Task.query.all() reads every task.
Which call fetches one record or returns a 404 if missing?
- Task.query.one()
- db.get_or_404(Task, id)
- Task.find(id)
- db.fetch(Task, id)
Answer: db.get_or_404(Task, id). db.get_or_404(Task, id) returns the record or a clean 404.
How do you remove a record?
- db.remove(obj)
- obj.delete()
- db.session.delete(obj)
- Task.query.drop(obj)
Answer: db.session.delete(obj). db.session.delete(obj) removes the record (then commit).
If a form POSTs to a GET-only route, what error appears?
- 404 Not Found
- 401 Unauthorized
- 500 Internal Server Error
- 405 Method Not Allowed
Answer: 405 Method Not Allowed. 405 Method Not Allowed means the route doesn't accept POST; add methods=['POST'].
Why do changes vanish or never appear?
- You forgot db.session.commit()
- DEBUG was True
- The model was uppercase
- You used a Blueprint
Answer: You forgot db.session.commit(). Without commit(), staged changes are never written to the database.
How does data flow in a typical CRUD request?
- Template writes directly to the DB
- The browser queries SQLAlchemy
- A route reads/writes via SQLAlchemy, then renders a template
- Forms bypass routes
Answer: A route reads/writes via SQLAlchemy, then renders a template. A route uses SQLAlchemy to read/change data, then renders a template; forms POST back to routes.
Continue this course
- Previous: Checkpoint: Production-Ready Flask
- Next: API Docs with OpenAPI & Swagger — Auto-generate interactive Swagger docs with flask-smorest and marshmallow schemas
- Quick reference: Flask cheat sheet
Frequently asked questions
What is a CRUD app?
CRUD stands for Create, Read, Update, and Delete — the four operations every data-driven app performs. A CRUD app lets users add records, view them, edit them, and remove them.
How do routes, templates, and the database fit together?
A route receives the request, uses SQLAlchemy to read or change data, then renders an HTML template with that data. Forms in the template POST back to routes that create or update records.
Why use one combined project for the capstone?
Real apps are not isolated snippets — they weave routing, templates, models, and forms into a single working whole. The capstone shows how the pieces connect so you can build your own projects.
What should I build next after this course?
Pick a project you care about — a blog, a todo list, a recipe box — and rebuild it from scratch. Add authentication and deploy it. Building something real is the fastest way to cement what you learned.