Skip to main content

🗄️ Database Integration with Flask-SQLAlchemy

A web app without a database forgets everything the moment it restarts. This lesson connects Flask to a real database through Flask-SQLAlchemy — an ORM that lets you work with rows as ordinary Python objects, model the relationships between them, and query them without hand-writing SQL.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain what an ORM is and why databases matter for web apps
  • Configure Flask-SQLAlchemy and choose a database URI
  • Define models with columns, constraints, and relationships
  • Perform CRUD operations and write filtered, ordered, paginated queries
  • Manage schema changes safely with Flask-Migrate

Estimated Time: 45–55 minutes  •  Difficulty: Intermediate

Hands-on: Model a blog with related users and posts, then query it.

In This Lesson

Why Databases & What's an ORM?

Databases exist because applications need to remember things reliably. A database gives you:

  • Persistence — data survives restarts and crashes
  • Structure & integrity — rules and constraints keep data valid
  • Efficient retrieval — indexed queries find records fast
  • Concurrency — many users read and write safely at once
💡 The library analogy: A database is a library. Data are the books, tables are the shelves, and the schema is the catalog system. The database engine is the librarian who finds a book instantly, enforces the rules, and makes sure two people don't check out the same copy.

What is an ORM?

An Object-Relational Mapper translates between two worlds: Python objects and relational database rows. Instead of writing raw SQL, you work with classes and instances, and the ORM generates the SQL for you.

How an ORM maps objects to rows A Python User object on the left is mapped by the ORM into an INSERT SQL statement and a table row on the right, and query results are mapped back into objects. Python world user = User( username='ray') objects & attributes Database world INSERT INTO users (username) VALUES ('ray') tables & rows ORM maps → ← ORM translates
Figure 1 — The ORM sits between your Python code and the database, converting objects into SQL and rows back into objects.

SQLAlchemy is the leading Python ORM. Flask-SQLAlchemy wraps it with Flask-friendly defaults: automatic session handling tied to the request lifecycle, a base db.Model class, and helpers like db.paginate() and db.get_or_404().

Setting Up Flask-SQLAlchemy

Install the extension. For anything other than SQLite you also need a driver:

pip install Flask-SQLAlchemy

# Database drivers (pick what you use)
pip install psycopg2-binary   # PostgreSQL
pip install mysqlclient        # MySQL / MariaDB
# SQLite needs no driver — it's built into Python

Configure the database URI and create the db object. This example uses the modern application-factory-friendly style:

import os
from flask import Flask
from flask_sqlalchemy import SQLAlchemy

app = Flask(__name__)
# Use an env var in production; fall back to a local SQLite file in dev
app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get(
    'DATABASE_URL', 'sqlite:///site.db'
)

db = SQLAlchemy(app)

Database URIs at a glance

DatabaseURI format
SQLitesqlite:///site.db
PostgreSQLpostgresql://user:pass@localhost:5432/mydb
MySQLmysql://user:pass@localhost:3306/mydb

💡 SQLite for development, Postgres for production

SQLite is a single file — perfect for learning and local development, zero setup. For production, a client-server database like PostgreSQL handles real concurrency. Because the ORM abstracts the SQL, you can often develop on SQLite and deploy on Postgres with only a URI change.

Defining Models

A model is a Python class that maps to a database table. Each db.Column attribute becomes a column. Subclass db.Model:

from datetime import datetime, timezone

class User(db.Model):
    __tablename__ = 'users'   # optional; defaults to a lowercased class name

    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(80), unique=True, nullable=False)
    email = db.Column(db.String(120), unique=True, nullable=False, index=True)
    created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))

    def __repr__(self):
        return f'<User {self.username}>'

This defines a users table with four columns. The __repr__ makes objects readable when debugging in the shell.

Common column types & options

Column typeStores
db.IntegerWhole numbers
db.String(n)Text up to n characters
db.TextUnlimited-length text
db.DateTimeDate and time
db.Numeric(p, s)Exact decimals (money!)
db.BooleanTrue / False

Column options fine-tune each column: primary_key=True, unique=True, nullable=False, index=True, default=value, and onupdate=func (run on every update).

⚠️ Use Numeric for money, never Float

Floating-point columns can't represent values like 0.10 exactly, which leads to rounding errors in prices and totals. Use db.Numeric(10, 2) for currency.

Relationships Between Models

The real power of a relational database is connecting tables. There are four relationship shapes:

graph LR A[User] -->|one-to-many| B[Post] C[Post] -->|many-to-one| D[Category] E[User] -->|one-to-one| F[Profile] G[Post] -->|many-to-many| H[Tag]

One-to-many (the most common)

One user writes many posts. The "many" side holds a foreign key; the "one" side declares a relationship:

class User(db.Model):
    __tablename__ = 'users'
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(80), unique=True, nullable=False)

    # Access every post by this user via user.posts
    posts = db.relationship('Post', back_populates='author', lazy=True)


class Post(db.Model):
    __tablename__ = 'posts'
    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(120), nullable=False)
    content = db.Column(db.Text, nullable=False)

    # The foreign key points at users.id
    user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
    # Access the writer via post.author
    author = db.relationship('User', back_populates='posts')

Now some_user.posts is a list of that user's posts, and some_post.author is the User who wrote it. Using matched back_populates on both sides makes the two-way link explicit and readable.

Many-to-many

A post can have many tags, and a tag applies to many posts. This needs an association table:

post_tags = db.Table(
    'post_tags',
    db.Column('post_id', db.Integer, db.ForeignKey('posts.id'), primary_key=True),
    db.Column('tag_id', db.Integer, db.ForeignKey('tags.id'), primary_key=True),
)

class Post(db.Model):
    __tablename__ = 'posts'
    id = db.Column(db.Integer, primary_key=True)
    tags = db.relationship('Tag', secondary=post_tags, back_populates='posts')

class Tag(db.Model):
    __tablename__ = 'tags'
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(50), unique=True, nullable=False)
    posts = db.relationship('Post', secondary=post_tags, back_populates='tags')

One-to-one

Add uselist=False so the relationship returns a single object instead of a list, and mark the foreign key unique=True:

class User(db.Model):
    # ...
    profile = db.relationship('Profile', back_populates='user',
                              uselist=False)

class Profile(db.Model):
    __tablename__ = 'profiles'
    id = db.Column(db.Integer, primary_key=True)
    bio = db.Column(db.Text)
    user_id = db.Column(db.Integer, db.ForeignKey('users.id'),
                        unique=True, nullable=False)
    user = db.relationship('User', back_populates='profile')

Creating the Database

Once models are defined, create the tables. Flask-SQLAlchemy needs an application context to know which app (and database) to talk to:

with app.app_context():
    db.create_all()   # creates any tables that don't yet exist

⚠️ create_all() does not alter existing tables

db.create_all() only creates tables that are missing. If you add a column to a model later, it will not update the existing table — you need migrations for that (Section 8). And db.drop_all() destroys every table and all its data, so keep it out of production code.

CRUD Operations

CRUD — Create, Read, Update, Delete — is the everyday work of a database app. Flask-SQLAlchemy routes everything through db.session, a staging area for changes that you commit() to make permanent.

Create

user = User(username='ray', email='ray@example.com')
db.session.add(user)
db.session.commit()   # nothing is saved until you commit

Read

Modern SQLAlchemy 2.x style uses db.session.get() for primary-key lookups and db.select() for queries:

# Fetch by primary key
user = db.session.get(User, 1)

# Fetch by attribute
user = db.session.scalar(
    db.select(User).filter_by(username='ray')
)

# All matching rows
recent = db.session.scalars(
    db.select(User).order_by(User.created_at.desc()).limit(5)
).all()

Update

user = db.session.get(User, 1)
user.email = 'new@example.com'   # just change the attribute
db.session.commit()              # commit saves it

Delete

user = db.session.get(User, 1)
db.session.delete(user)
db.session.commit()

The session workflow in one line:

add / change / delete  →  db.session.commit()  →  persisted

Querying & Filtering

SQLAlchemy's query API expresses complex SQL in Python. A few patterns cover most needs:

from sqlalchemy import or_, func

# WHERE with multiple conditions
users = db.session.scalars(
    db.select(User).where(
        User.active == True,
        User.email.endswith('@example.com'),
    )
).all()

# OR conditions
admins = db.session.scalars(
    db.select(User).where(
        or_(User.role == 'admin', User.role == 'moderator')
    )
).all()

# Aggregation — count active users
active_count = db.session.scalar(
    db.select(func.count()).select_from(User).where(User.active == True)
)

# Join posts to their authors
rows = db.session.execute(
    db.select(Post, User).join(User, Post.user_id == User.id)
).all()

Pagination

For list pages, db.paginate() returns a page object with .items, .pages, .has_next, and more:

@app.route('/users')
def list_users():
    page = request.args.get('page', 1, type=int)
    pagination = db.paginate(
        db.select(User).order_by(User.username),
        page=page, per_page=20, error_out=False,
    )
    return render_template('users.html', pagination=pagination)

Fetch-or-404

In views, db.get_or_404() and db.first_or_404() raise a clean 404 when a record is missing — no manual if user is None checks:

@app.route('/users/<int:user_id>')
def show_user(user_id):
    user = db.get_or_404(User, user_id)
    return render_template('user.html', user=user)

✅ Prefer the ORM over raw SQL

The ORM parameterizes queries automatically, which prevents SQL injection, and it papers over dialect differences between databases. Reach for raw SQL (db.session.execute(text(...))) only for the rare query the ORM can't express well — and even then, always bind parameters, never format strings.

Migrations with Flask-Migrate

Once your app is live with real data, you can't just drop_all() to change the schema. Flask-Migrate (built on Alembic) generates versioned migration scripts that alter tables in place — add a column, rename it, create an index — without losing data.

from flask_migrate import Migrate

migrate = Migrate(app, db)

Then drive it from the command line:

flask db init                          # once, sets up the migrations folder
flask db migrate -m "add active flag"  # auto-generate a migration from model changes
flask db upgrade                        # apply it to the database
flask db downgrade                      # roll back the last migration if needed

💡 Always review generated migrations

Alembic's autogenerate is good but not perfect — it can miss renames or subtle type changes. Open the generated script in migrations/versions/ and read it before running upgrade, especially in production.

Hands-on Exercise

🏋️ Model & Query a Mini Blog

Objective: Build a two-model blog schema with a one-to-many relationship, then write queries against it.

Instructions:

  1. Define a User model (id, username unique) and a Post model (id, title, body, created_at, foreign key to user).
  2. Link them one-to-many so user.posts and post.author both work.
  3. Create the tables, add one user with two posts, and commit.
  4. Write a query that returns that user's posts, newest first.
💡 Hint

Because you set up the relationship, once you assign post.author = user (or append to user.posts), adding and committing the user cascades the posts. Order with .order_by(Post.created_at.desc()).

✅ Sample solution
from datetime import datetime, timezone

class User(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(80), unique=True, nullable=False)
    posts = db.relationship('Post', back_populates='author', lazy=True)

class Post(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(120), nullable=False)
    body = db.Column(db.Text, nullable=False)
    created_at = db.Column(db.DateTime,
                           default=lambda: datetime.now(timezone.utc))
    user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
    author = db.relationship('User', back_populates='posts')

with app.app_context():
    db.create_all()

    ray = User(username='ray')
    ray.posts = [
        Post(title='Hello', body='First post'),
        Post(title='Again', body='Second post'),
    ]
    db.session.add(ray)
    db.session.commit()

    # Query: this user's posts, newest first
    newest = db.session.scalars(
        db.select(Post)
          .where(Post.user_id == ray.id)
          .order_by(Post.created_at.desc())
    ).all()
    for p in newest:
        print(p.title, '—', p.author.username)

🎯 Quick Quiz

Question 1: What is the main job of an ORM like SQLAlchemy?

Question 2: In a one-to-many relationship (one user, many posts), where does the foreign key live?

Question 3: You added a new column to a model in a live app. What should you use to update the existing table safely?

Summary & Quiz

🎉 Key Takeaways

  • An ORM lets you work with database rows as Python objects; SQLAlchemy generates the SQL.
  • Models subclass db.Model; columns and constraints are declared with db.Column.
  • Relationships (one-to-many, many-to-many, one-to-one) connect tables via foreign keys and db.relationship.
  • All changes flow through db.session and only persist after commit().
  • Prefer the ORM (it prevents SQL injection) and use Flask-Migrate for schema changes.

📚 Further Reading

🚀 What's Next?

You can now collect data with forms and store it in a database. As the app grows, though, a single file gets unwieldy. Next we organize everything into modular Blueprints and the application factory pattern.

🎉 Great progress!

Your app now has a memory. Let's give its code a clean structure.