If you know some Python and want to put something on the web — a personal dashboard, an internal tool for work, an API for a mobile app, or your first real web application — Flask is one of the best places to start. It’s small enough to learn in an afternoon, yet powerful enough to run production applications at companies of every size.
This guide takes you from an empty folder to a deployed application. You’ll learn what Flask is and when to use it, then build a small task-list app step by step: routes, templates, forms, a database, a JSON API, and tests. Finally, you’ll deploy it to a real server.
What Is Flask?
Flask is a lightweight web framework for Python. It’s often called a microframework, which doesn’t mean it’s only for small projects — it means Flask gives you a small, solid core and lets you choose everything else.
Out of the box, Flask handles:
- Routing — mapping URLs like
/aboutor/tasks/5to Python functions. - Requests and responses — reading form data, query strings, JSON, and cookies, and sending back HTML, JSON, or redirects.
- Templates — generating HTML with the Jinja2 template engine.
- A development server and debugger — so you can run and test your app locally.
What Flask doesn’t include — a database layer, user accounts, form validation, an admin panel — you add with extensions only when you need them. Popular ones include Flask-SQLAlchemy (databases), Flask-Login (user sessions), Flask-WTF (forms), and Flask-Migrate (database migrations).
Flask vs. Django vs. FastAPI
These are the three most popular Python web frameworks, and each has a sweet spot:
- Flask — minimal and flexible. Great for small to medium apps, APIs, prototypes, and learning how web apps actually work.
- Django — “batteries included.” It ships with an ORM, admin panel, authentication, and much more. Great for large, database-heavy sites where you want conventions decided for you.
- FastAPI — built for modern APIs, with automatic data validation and interactive API docs. Great when your project is mostly a JSON API.
If you’re not sure, Flask is a safe choice: what you learn transfers easily to the other two.
Why Use Flask? The Benefits
- It’s easy to learn. A working Flask app fits in five lines of code. There’s very little “magic” — you can read your app top to bottom and understand what happens.
- You choose your tools. Use any database, any front-end, any authentication method. Flask doesn’t force a structure on you.
- It scales with your project. Start with a single file, then grow into blueprints and an application factory as your app gets bigger.
- It’s mature and well-documented. Flask has been around since 2010, has excellent official documentation, and a large community with answers to almost any question.
- It’s great for APIs. Return a Python dictionary from a route and Flask turns it into JSON automatically.
- It’s free and open source. Flask is released under the BSD license and maintained by the Pallets project.
Prerequisites
To follow along, you’ll need:
- Python 3.9 or newer (a current release like 3.12 or 3.13 is recommended). If you’re new to the language, start with Python Basics: What Every Beginner Should Know.
- A terminal and a code editor such as VS Code.
- Basic familiarity with HTML.
Step 1: Set Up Your Project
Create a project folder and a virtual environment so Flask and its dependencies stay isolated from the rest of your system:
mkdir flask-tasks
cd flask-tasks
python3 -m venv .venv
source .venv/bin/activate # On Windows: .venv\Scripts\activate
Then install Flask:
pip install flask
Prefer uv? If you use uv, the modern Python package manager, run
uv initanduv add flaskinstead — it creates and manages the virtual environment for you.
Confirm it installed correctly:
flask --version
Step 2: Your First Flask App
Create a file called hello.py:
from flask import Flask
app = Flask(__name__)
@app.route("/")
def home():
return "Hello, Flask!"
Here’s what’s going on:
Flask(__name__)creates your application.__name__tells Flask where to look for templates and static files.@app.route("/")is a decorator that connects the URL/to thehome()function.- Whatever the function returns is sent back to the browser.
Run it with the development server:
flask --app hello run --debug
Open http://127.0.0.1:5000 in your browser and you’ll see “Hello, Flask!” The --debug flag turns on automatic reloading (the server restarts when you save a file) and an interactive debugger in the browser when something goes wrong.
Never use
--debugin production. The debugger lets anyone who can see the error page run Python code on your server.
Step 3: Routing and Dynamic URLs
Real apps need more than one page. Add routes for each URL, and use variables in the URL for dynamic pages:
@app.route("/about")
def about():
return "This is the about page."
@app.route("/hello/<name>")
def hello(name):
return f"Hello, {name}!"
@app.route("/tasks/<int:task_id>")
def show_task(task_id):
return f"You asked for task number {task_id}."
Visiting /hello/Victor returns “Hello, Victor!” The <int:task_id> converter makes sure the value is a number — /tasks/abc returns a 404 instead of crashing. Other converters include string (the default), float, and path.
HTTP Methods
By default, a route only accepts GET requests. To handle form submissions, allow POST too — or use the shortcut decorators @app.get() and @app.post():
from flask import request
@app.route("/contact", methods=["GET", "POST"])
def contact():
if request.method == "POST":
return "Thanks for your message!"
return "Show the contact form here."
Step 4: Structure Your Project
A single file is fine for experiments, but it gets messy fast. For the rest of this guide, we’ll build a small task-list app with a structure that can grow:
flask-tasks/
├── app/
│ ├── __init__.py # application factory
│ ├── models.py # database models
│ ├── routes.py # URL routes (a blueprint)
│ ├── static/
│ │ └── style.css
│ └── templates/
│ ├── base.html
│ └── index.html
├── tests/
│ └── test_app.py
├── .env
├── requirements.txt
└── wsgi.py # entry point for production servers
Two Flask patterns make this structure work:
- The application factory — instead of creating
appat the top of a file, you create it inside acreate_app()function. This makes it easy to create apps with different settings, for example, one for testing. - Blueprints — a blueprint is a group of related routes. Large apps split their routes into several blueprints (
auth,blog,api, and so on) and register them with the app.
Step 5: Add a Database with Flask-SQLAlchemy
Our app needs to store tasks. Install Flask-SQLAlchemy, which connects Flask to the SQLAlchemy ORM so you can work with database rows as Python objects:
pip install flask-sqlalchemy python-dotenv
Create app/__init__.py with the application factory:
import os
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
def create_app(test_config=None):
app = Flask(__name__)
app.config.from_mapping(
SECRET_KEY=os.environ.get("SECRET_KEY", "dev"),
SQLALCHEMY_DATABASE_URI=os.environ.get("DATABASE_URL", "sqlite:///tasks.db"),
)
if test_config:
app.config.update(test_config)
db.init_app(app)
from .routes import bp
app.register_blueprint(bp)
with app.app_context():
db.create_all()
return app
Then define a model in app/models.py. Each class becomes a table, and each attribute becomes a column:
from . import db
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)
db.create_all() creates any tables that don’t exist yet. With SQLite, the database file is created in an instance/ folder in your project. SQLite is perfect for learning and small apps; for production, you can switch to PostgreSQL or MySQL by changing only the DATABASE_URL.
Changing your models later?
db.create_all()won’t modify existing tables. Once your app is in use, add Flask-Migrate to manage schema changes safely.
Step 6: Write the Routes
Create app/routes.py. This blueprint shows the task list, adds tasks, marks them as done, and deletes them:
from flask import Blueprint, flash, redirect, render_template, request, url_for
from . import db
from .models import Task
bp = Blueprint("tasks", __name__)
@bp.get("/")
def index():
tasks = db.session.execute(db.select(Task).order_by(Task.id)).scalars().all()
return render_template("index.html", tasks=tasks)
@bp.post("/add")
def add():
title = request.form.get("title", "").strip()
if not title:
flash("Please enter a task title.")
return redirect(url_for("tasks.index"))
db.session.add(Task(title=title))
db.session.commit()
flash("Task added.")
return redirect(url_for("tasks.index"))
@bp.post("/toggle/<int:task_id>")
def toggle(task_id):
task = db.get_or_404(Task, task_id)
task.done = not task.done
db.session.commit()
return redirect(url_for("tasks.index"))
@bp.post("/delete/<int:task_id>")
def delete(task_id):
task = db.get_or_404(Task, task_id)
db.session.delete(task)
db.session.commit()
flash("Task deleted.")
return redirect(url_for("tasks.index"))
A few things worth noticing:
request.formholds the data submitted by an HTML form.redirect(url_for(...))sends the browser back to the list after a change. This is the Post/Redirect/Get pattern — it prevents a duplicate submission if the user refreshes the page.url_for("tasks.index")builds URLs from function names (blueprint.function) instead of hard-coding paths, so your links don’t break if a URL changes.db.get_or_404()returns the task, or a 404 page if it doesn’t exist.flash()stores a one-time message to show on the next page. It uses the session, which is why the app needs aSECRET_KEY.
Step 7: Templates with Jinja2
Returning strings works for testing, but real pages need HTML. Flask uses Jinja2 templates, stored in the templates/ folder.
Start with a base layout in app/templates/base.html. Every page will share it:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Flask Tasks{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<main>
{% for message in get_flashed_messages() %}
<p class="flash">{{ message }}</p>
{% endfor %}
{% block content %}{% endblock %}
</main>
</body>
</html>
Then create app/templates/index.html, which extends the base layout and fills in the content block:
{% extends "base.html" %}
{% block content %}
<h1>My Tasks</h1>
<form action="{{ url_for('tasks.add') }}" method="post">
<input type="text" name="title" placeholder="What needs to be done?" required>
<button type="submit">Add</button>
</form>
<ul>
{% for task in tasks %}
<li class="{{ 'done' if task.done }}">
{{ task.title }}
<form action="{{ url_for('tasks.toggle', task_id=task.id) }}" method="post">
<button type="submit">{{ 'Undo' if task.done else 'Done' }}</button>
</form>
<form action="{{ url_for('tasks.delete', task_id=task.id) }}" method="post">
<button type="submit">Delete</button>
</form>
</li>
{% else %}
<li>No tasks yet. Add one above!</li>
{% endfor %}
</ul>
{% endblock %}
The Jinja2 syntax is easy to pick up:
{{ ... }}outputs a value.{% ... %}runs logic, such asforloops,ifstatements, and blocks.{% extends %}and{% block %}let pages share a common layout.
Jinja2 escapes HTML automatically, so if someone enters <script> as a task title, it’s displayed as text instead of running in the browser. That protects you from a whole class of cross-site scripting (XSS) attacks.
Finally, add some simple styles in app/static/style.css:
body { font-family: system-ui, sans-serif; max-width: 600px; margin: 2rem auto; padding: 0 1rem; }
li { display: flex; gap: 0.5rem; align-items: center; margin: 0.5rem 0; }
li form { display: inline; }
.done { text-decoration: line-through; color: #888; }
.flash { background: #eef6ff; padding: 0.5rem 1rem; border-radius: 6px; }
Step 8: Configuration and Secrets
Never hard-code secrets like your SECRET_KEY or database password in your code. Store them in a .env file instead, and add .env to your .gitignore so it never ends up in your repository:
SECRET_KEY=replace-this-with-a-long-random-string
DATABASE_URL=sqlite:///tasks.db
Generate a strong secret key with:
python -c "import secrets; print(secrets.token_hex(32))"
When python-dotenv is installed, the flask command loads .env automatically. Our create_app() reads the values with os.environ.get().
Now create wsgi.py in the project root. Production servers will use it as the entry point:
from app import create_app
app = create_app()
Run the app:
flask --app app run --debug
Flask finds the create_app() function on its own. Open http://127.0.0.1:5000 and add a few tasks — you now have a working, database-backed web app.
Step 9: Build a JSON API
Flask makes APIs simple: if a route returns a dictionary or a list, Flask converts it to JSON automatically. Add these routes to app/routes.py:
@bp.get("/api/tasks")
def api_list_tasks():
tasks = db.session.execute(db.select(Task).order_by(Task.id)).scalars()
return [{"id": t.id, "title": t.title, "done": t.done} for t in tasks]
@bp.post("/api/tasks")
def api_create_task():
data = request.get_json(silent=True) or {}
title = str(data.get("title", "")).strip()
if not title:
return {"error": "title is required"}, 400
task = Task(title=title)
db.session.add(task)
db.session.commit()
return {"id": task.id, "title": task.title, "done": task.done}, 201
Returning a tuple like (data, 201) sets the HTTP status code. Test the API with curl:
curl http://127.0.0.1:5000/api/tasks
curl -X POST http://127.0.0.1:5000/api/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Learn Flask"}'
Step 10: Test Your App
Flask includes a test client that sends fake requests to your app without starting a server. Install pytest:
pip install pytest
Create tests/test_app.py:
import pytest
from app import create_app
@pytest.fixture
def client():
app = create_app({
"TESTING": True,
"SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:",
})
return app.test_client()
def test_home_page(client):
response = client.get("/")
assert response.status_code == 200
assert b"My Tasks" in response.data
def test_add_task(client):
response = client.post("/add", data={"title": "Write tests"}, follow_redirects=True)
assert b"Write tests" in response.data
def test_api_requires_title(client):
response = client.post("/api/tasks", json={})
assert response.status_code == 400
The fixture uses an in-memory SQLite database, so every test starts with a clean, empty database. This is exactly why the application factory is useful. Run the tests from the project root:
python -m pytest
Step 11: Prepare for Production
The flask run server is built for development only — it isn’t designed to be fast, stable, or secure under real traffic. Before deploying, go through this checklist:
- Turn off debug mode. Never run with
--debugorFLASK_DEBUG=1in production. - Set a real
SECRET_KEYfrom an environment variable, never the"dev"default. - Use a production WSGI server such as Gunicorn (Linux/macOS) or Waitress (works on Windows too).
- Pin your dependencies so the server installs exactly what you tested:
pip install gunicorn
pip freeze > requirements.txt
- Use a real database like PostgreSQL if several people will use the app at the same time.
- Serve over HTTPS.
Test Gunicorn locally before you deploy:
gunicorn -w 3 -b 127.0.0.1:8000 wsgi:app
wsgi:app means “the app object inside wsgi.py,” and -w 3 starts three worker processes so the app can handle several requests at once. A common rule of thumb is (2 × CPU cores) + 1 workers.
Step 12: Deploy Your Flask App
There are three common ways to deploy a Flask app. Pick the one that fits your experience and budget.
Option 1: A Linux Server with Gunicorn and Nginx
This is the classic setup, and it runs on any VPS (DigitalOcean, Linode, Hetzner, and so on) or a home server. Gunicorn runs your Python code, and Nginx sits in front of it to handle HTTPS, static files, and incoming connections.
1. Copy your project to the server and install it:
sudo apt update && sudo apt install python3-venv nginx
cd /var/www
sudo git clone https://github.com/your-username/flask-tasks.git
sudo chown -R $USER:www-data flask-tasks
cd flask-tasks
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
Create the .env file on the server with your production SECRET_KEY.
2. Run Gunicorn as a systemd service so it starts on boot and restarts if it crashes. Create /etc/systemd/system/flask-tasks.service:
[Unit]
Description=Flask Tasks (Gunicorn)
After=network.target
[Service]
User=www-data
Group=www-data
WorkingDirectory=/var/www/flask-tasks
EnvironmentFile=/var/www/flask-tasks/.env
ExecStart=/var/www/flask-tasks/.venv/bin/gunicorn -w 3 -b 127.0.0.1:8000 wsgi:app
Restart=always
[Install]
WantedBy=multi-user.target
The www-data user needs write access to the instance/ folder where SQLite stores the database. Then start the service:
sudo systemctl daemon-reload
sudo systemctl enable --now flask-tasks
sudo systemctl status flask-tasks
3. Configure Nginx as a reverse proxy. Create /etc/nginx/sites-available/flask-tasks:
server {
listen 80;
server_name tasks.example.com;
location /static/ {
alias /var/www/flask-tasks/app/static/;
}
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Enable the site and reload Nginx:
sudo ln -s /etc/nginx/sites-available/flask-tasks /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
4. Add free HTTPS with Let’s Encrypt:
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d tasks.example.com
Certbot updates your Nginx configuration and renews the certificate automatically.
Option 2: Docker
If you already use Docker, packaging your app in a container makes it run the same way everywhere. Add a Dockerfile to the project root:
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["gunicorn", "-w", "3", "-b", "0.0.0.0:8000", "wsgi:app"]
Add a .dockerignore file containing .venv, .env, instance, and __pycache__ so they aren’t copied into the image. Then build and run it:
docker build -t flask-tasks .
docker run -d -p 8000:8000 --env-file .env --name flask-tasks flask-tasks
You can put the same Nginx and Certbot setup from Option 1 in front of the container.
Option 3: A Platform as a Service (PaaS)
If you’d rather not manage a server at all, platforms like Render, Railway, Fly.io, and PythonAnywhere can deploy a Flask app straight from your GitHub repository. Usually, you just:
- Push your code (with
requirements.txtandwsgi.py) to GitHub. - Connect the repository to the platform.
- Set the start command to
gunicorn wsgi:app. - Add
SECRET_KEYandDATABASE_URLas environment variables in the platform’s dashboard.
Many of these platforms offer free or low-cost plans that are perfect for side projects. Keep in mind that their file systems are often temporary, so use a managed database instead of SQLite.
After You Deploy: Monitor Your App
Once your app is live, you’ll want to know right away if it goes down. A self-hosted tool like Uptime Kuma can check your app every minute and alert you on your phone the moment it stops responding.
Troubleshooting Common Flask Problems
“Error: Could not locate a Flask application.” Flask can’t find your app. Pass it explicitly with --app, for example, flask --app hello run for a single file, or flask --app app run for a package with create_app(). Make sure you run the command from the project root.
“Address already in use.” Another program is using port 5000. On macOS, AirPlay Receiver often uses it. Run Flask on a different port with flask --app app run --port 5001.
TemplateNotFound error. Flask looks for templates in a templates/ folder next to the module where you created the app. Check the folder name (it’s plural) and its location.
“The session is unavailable because no secret key was set.” flash() and sessions need a SECRET_KEY. Set it in your config or .env file.
“Working outside of application context.” You used db or current_app outside a request — for example, in a script. Wrap that code in with app.app_context():.
Changes to models don’t appear in the database. db.create_all() only creates missing tables; it never alters existing ones. Use Flask-Migrate, or delete the SQLite file during development to start fresh.
502 Bad Gateway from Nginx. Gunicorn isn’t running or isn’t listening on the address in proxy_pass. Check sudo systemctl status flask-tasks and sudo journalctl -u flask-tasks -n 50 for errors.
Conclusion
Flask gives you just what you need to build a web application and gets out of your way for everything else. In this guide, you went from a five-line “Hello, Flask!” to a structured app with a database, HTML templates, a JSON API, and automated tests — and then deployed it with Gunicorn and Nginx, Docker, or a hosting platform.
From here, the best way to learn is to build something you’ll actually use. Add user accounts with Flask-Login, manage database changes with Flask-Migrate, or turn the API into the backend for a JavaScript front-end. Because Flask stays small, every new feature you add teaches you a little more about how the web really works.
