Connecting a Flask app to PostgreSQL with Encore, Flask-SQLAlchemy with Flask-Migrate, or psycopg 3 with a connection pool, plus migrations, local databases and production.
Flask leaves the database to you, so every Flask project assembles the same pieces around Postgres. That means a driver and maybe an ORM, a home for migrations, a database for each developer and CI run, and a production instance with its network and credentials.
This guide covers Encore, Flask-SQLAlchemy with Flask-Migrate, and psycopg 3 with a connection pool. Each version uses the same books table.
What you need to decide:
Encore combines a Postgres database declared in your Flask code with the server behind it, so encore run starts Postgres locally with your migrations applied and a deploy creates RDS or Cloud SQL in your own account. It is an infrastructure SDK added to an existing app, and Flask keeps its blueprints, views and extensions.

The SDK's query API is async, so Flask needs its async extra, and the example serves the app with Waitress:
[project]
name = "books"
version = "0.1.0"
requires-python = ">=3.9"
dependencies = [
"flask[async]>=3.1",
"waitress>=3.0",
"encore-dev",
"flask-sqlalchemy>=3.1",
"psycopg[binary]>=3.2",
]
Migrations are plain SQL files in ./migrations, numbered in the order they apply:
-- migrations/1_create_books.up.sql
CREATE TABLE books (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL,
author TEXT NOT NULL
);
The database is declared once at module level. Views query it with db.query(...) and pick .one(), .first() or .all() for the shape of the result, and parameters are passed separately from the SQL, so values are never interpolated into the query:
import os
from encore.storage import postgres
from flask import Flask, abort, request
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import String, func, select
from sqlalchemy.engine import make_url
from sqlalchemy.orm import Mapped, mapped_column
from waitress import serve
# Encore provisions this database, locally under `encore run` and in your
# cloud on deploy, and applies the SQL files in ./migrations first.
db = postgres.Database("books", migrations="./migrations")
app = Flask(__name__)
@app.post("/books")
async def create_book():
data = request.get_json()
row = await db.query(
"INSERT INTO books (title, author) VALUES ($1, $2) RETURNING id",
data["title"],
data["author"],
).one()
return {"id": row["id"]}, 201
@app.get("/books/<int:book_id>")
async def get_book(book_id: int):
row = await db.query(
"SELECT id, title, author FROM books WHERE id = $1", book_id
).first()
if row is None:
abort(404)
return {"id": row["id"], "title": row["title"], "author": row["author"]}
@app.get("/books")
async def list_books():
rows = await db.query("SELECT id, title, author FROM books ORDER BY id").all()
return [{"id": r["id"], "title": r["title"], "author": r["author"]} for r in rows]
Projects that already use Flask-SQLAlchemy keep their models. db.connection_string points SQLAlchemy at the same database through psycopg 3, so ORM code and SDK queries share one Postgres:
# Optional: hand the same database to Flask-SQLAlchemy through psycopg 3.
app.config["SQLALCHEMY_DATABASE_URI"] = make_url(db.connection_string).set(
drivername="postgresql+psycopg"
)
orm = SQLAlchemy(app)
class Book(orm.Model):
__tablename__ = "books"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String)
author: Mapped[str] = mapped_column(String)
@app.get("/books/count")
def count_books():
total = orm.session.scalar(select(func.count()).select_from(Book))
return {"count": total}
if __name__ == "__main__":
# Encore's local run sets ENCORE_LISTEN_ADDR to the "host:port" to bind.
host, _, port = os.environ.get("ENCORE_LISTEN_ADDR", "0.0.0.0:8000").rpartition(":")
serve(app, host=host or "0.0.0.0", port=int(port))
encore run starts Postgres locally and applies the migration before the app takes requests:
Each developer gets their own local database, so an agent that adds a column writes the migration and the view, and checks both against Postgres on its own machine. On deploy, Encore creates RDS on AWS or Cloud SQL on GCP in your own account, and applies the same migrations. If the app already has an RDS database, Encore can import the existing instance instead of creating a new one, and each environment gets its own database.
Encore runs the SQL migrations in ./migrations, so the schema lives there rather than in orm.create_all() or Alembic. The ORM in this setup maps onto tables the migrations create.
Flask-SQLAlchemy is the usual ORM choice, and Flask-Migrate wraps Alembic in flask db commands. The database URL comes from the environment:
import os
from flask import Flask
from flask_migrate import Migrate
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = os.environ["DATABASE_URL"]
app.config["SQLALCHEMY_ENGINE_OPTIONS"] = {"pool_pre_ping": True, "pool_size": 5}
db = SQLAlchemy(app)
migrate = Migrate(app, db)
class Book(db.Model):
__tablename__ = "books"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String)
author: Mapped[str] = mapped_column(String)
Migrations are generated from the models with flask db migrate -m "create books" and applied with flask db upgrade.
Set DATABASE_URL with the postgresql+psycopg:// scheme to use psycopg 3, since plain postgresql:// selects psycopg2. Autogenerated migrations miss some changes, such as renamed columns, so read each one before committing it.
Without an ORM, psycopg_pool keeps a pool of connections per process, opened once at startup:
import os
from flask import Flask, abort
from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool
app = Flask(__name__)
pool = ConnectionPool(os.environ["DATABASE_URL"], min_size=1, max_size=5, open=True)
@app.get("/books/<int:book_id>")
def get_book(book_id: int):
with pool.connection() as conn:
row = conn.execute(
"SELECT id, title, author FROM books WHERE id = %s",
(book_id,),
).fetchone()
if row is None:
abort(404)
return {"id": row[0], "title": row[1], "author": row[2]}
The pool is per process, so a Gunicorn server with four workers holds up to four times max_size connections. Migrations need a tool of their own, such as Alembic on its own or a SQL migration runner.
Flask supports async def views when installed with the async extra, and the Flask documentation on async explains that each request still occupies one worker, because Flask runs the coroutine in an event loop per request rather than serving many requests on one loop. That is fine for awaiting a database query, and an app that needs high concurrency on one process is better served by an ASGI framework such as Quart or FastAPI.
On AWS this is usually RDS in private subnets with the password in Secrets Manager and a security group that admits only the app, and on GCP it is Cloud SQL with the Cloud SQL Client role on the service account. RDS enforces SSL by default on PostgreSQL 15 and later through rds.force_ssl, so the connection string needs sslmode=require or stricter. With Encore, the instance, network rules and credentials are created in your account and cloud access sits with Encore's IAM role, not the agent.
Python support in Encore is in beta, and you can sign up for access to try it with your Flask app.
Install psycopg 3, then either set SQLALCHEMY_DATABASE_URI for Flask-SQLAlchemy or open a psycopg connection pool when the app starts, with the connection details from the environment. With Encore, declare db = postgres.Database("books", migrations="./migrations") and query it with await db.query(sql, *args).one(), .first() or .all() from async Flask views. encore run starts Postgres locally and applies the migrations, and db.connection_string hands the same database to Flask-SQLAlchemy.
Flask-SQLAlchemy gives you models, sessions and Alembic migrations through Flask-Migrate, which suits most CRUD apps. Raw SQL through psycopg or a query API keeps the SQL visible and avoids an ORM layer, which some teams prefer for reporting queries and performance-sensitive paths.
psycopg 3 for new projects. SQLAlchemy selects it with the postgresql+psycopg driver name, and psycopg_pool provides a connection pool when you use it directly.
Install Postgres or run it in a container and point DATABASE_URL at it. With Encore, encore run starts Postgres locally for each database the code declares and applies the SQL migrations before the app starts.
Deploy to your cloud on AWS and Google Cloud
without managing infrastructure
Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.