Data and tooling
The tooling around Python code: tests with pytest, environments and dependencies, database access with SQLAlchemy 2, Alembic migrations and settings with pydantic-settings. Almost everything has a .NET counterpart — xUnit, NuGet, EF Core — and interviews focus on the differences.
Before this topic: The Python language, EF Core.
How do you write tests with pytest?
Section titled “How do you write tests with pytest?”What it is
pytest is the most widely used test framework in Python. A test is a function named test_... in a test_*.py or *_test.py file, and the check is a plain assert.
How to use
- Write a
test_parse_pricefunction containingassert parse_price("$300") == 300. - Run
pytestin the project root — it finds the tests itself. - Check exceptions with a
with pytest.raises(ValueError):block. - One test over many inputs is parametrization with
@pytest.mark.parametrize, like[Theory]in xUnit. - Move setup into a fixture with
@pytest.fixture. Built-in fixtures:tmp_path— a temporary directory,monkeypatch— temporary replacement of attributes and environment variables.
Common mistake
Naming the file prices_tests.py or the function check_price. pytest will not collect them and reports no tests ran — the test silently does not run.
Takeaway: a pytest test is a test_* function with assert, and setup arrives as fixtures matched by parameter name.
How it works
- Collection. pytest looks for
test_*.pyfiles and, inside them,test_*functions andTest*classes without__init__. - Assertion rewriting. When importing test modules pytest rewrites
assert, so the report shows both sides of the comparison. Methods likeAssert.Equalare not needed. - Fixtures. pytest reads the test’s parameter names, finds fixtures with those names and calls them, including through chains. Code after
yieldis the teardown. - Scopes.
scopeis how long the result lives:function(the default),class,module,package,session. - conftest.py. Its fixtures are available to tests in that directory and below without imports.
Compared with xUnit
| xUnit | pytest |
|---|---|
[Fact] |
a test_* function |
[Theory] and [InlineData] |
@pytest.mark.parametrize |
constructor and Dispose |
a fixture with yield |
IClassFixture, ICollectionFixture |
a module or session fixture in conftest.py |
Pitfalls
- A wide fixture cannot depend on a narrow one: a
sessionfixture usingmonkeypatchfails withScopeMismatch. - A mutable object from a
modulefixture is shared: one test corrupts another’s data. mock.patch("requests.get")does not replace a name already imported viafrom requests import get. You patch where the code under test looks the name up.
Rule of thumb
Default to function scope. Go wider only for expensive and immutable things.
Takeaway: pytest is DI for tests: fixtures are matched by name, and scope sets their lifetime.
Under the hood
- Fixtures form a graph. pytest creates each one on first request within its scope, caches it and tears it down in reverse order when the scope ends.
- A fixture with
params=[...]multiplies every test that depends on it — that is how one test suite runs against two databases. monkeypatchandmock.patchchange a module or class attribute and restore it in teardown. Forsessionfixtures there ispytest.MonkeyPatch.context().
Cost
- The only link to a fixture is the parameter name.
pytest --fixturesshows where it comes from. autouse=Trueapplies a fixture to every test in its scope, invisibly to the reader — keep such fixtures minimal.
Trade-offs
- Database. The engine and schema are a
sessionfixture, and the test session isSession(bind=connection, join_transaction_mode="create_savepoint")inside a transaction the fixture rolls back. It is faster than recreating the database, but the code’scommit()becomes a savepoint. - Mocks.
create_autospecchecks signatures, while a bareMockaccepts any call and hides typos. The more mocks, the tighter the test is coupled to the implementation. - Parallelism. pytest-xdist (
-n auto) runs several processes; shared resources are split by theworker_idfixture.
How to check in production
In CI use strict = true in the config (pytest 9+), a --junitxml report and --durations=10 for the slowest tests. The pytest-randomly plugin finds tests that depend on execution order.
Takeaway: fixtures are a graph with lifetimes: a narrow scope gives isolation, a wide one gives speed, and a per-test transaction rollback combines both.
# test_prices.py — run with: pytest -vimport pytest
def parse_price(text: str) -> int: digits = "".join(ch for ch in text if ch.isdigit()) if not digits: raise ValueError(f"no price in {text!r}") return int(digits)
@pytest.mark.parametrize( ("text", "expected"), [("12 500 UAH", 12500), ("$300", 300)], ids=["uah", "usd"],)def test_parse_price(text, expected): assert parse_price(text) == expected # two tests: [uah] and [usd]
def test_parse_price_without_digits(): with pytest.raises(ValueError, match="no price"): parse_price("negotiable")import jsonimport sqlite3
import pytest
@pytest.fixture(scope="module")def db(): conn = sqlite3.connect(":memory:") # one connection for all tests in the module conn.execute("CREATE TABLE ads (id INTEGER PRIMARY KEY, price INTEGER)") yield conn # the tests run here conn.close() # teardown — after the module's last test
def export_ads(conn, path): rows = conn.execute("SELECT id, price FROM ads ORDER BY id").fetchall() path.write_text(json.dumps(rows), encoding="utf-8")
def test_export(db, tmp_path): # tmp_path — a separate directory for each test db.execute("INSERT INTO ads (price) VALUES (12500)") out = tmp_path / "ads.json" export_ads(db, out) assert json.loads(out.read_text(encoding="utf-8")) == [[1, 12500]]from unittest.mock import create_autospec
class AdSource: def fetch(self, page: int) -> list[dict]: raise RuntimeError("network is not allowed in tests")
def count_cheap(source: AdSource, limit: int) -> int: return sum(1 for ad in source.fetch(page=1) if ad["price"] <= limit)
def test_with_monkeypatch(monkeypatch): # replace the attribute for the duration of the test only monkeypatch.setattr(AdSource, "fetch", lambda self, page: [{"price": 100}, {"price": 900}]) assert count_cheap(AdSource(), limit=500) == 1
def test_with_mock(): source = create_autospec(AdSource, instance=True) # same signatures as the real class source.fetch.return_value = [{"price": 100}] assert count_cheap(source, limit=500) == 1 source.fetch.assert_called_once_with(page=1)- How does pytest find tests? —
test_*.pyor*_test.pyfiles, withtest_*functions andTest*classes without__init__. - Why is a fixture better than
setUp? — It is plugged in by name only where needed, composes with other fixtures and runs its own teardown afteryield. - What happens if a
sessionfixture requeststmp_path? —ScopeMismatch: a wide scope cannot depend on a narrow one; usetmp_path_factoryinstead. - Where do you patch a function with
mock.patch? — In the module where the code under test looks it up, not where it is defined. - How do you run tests in parallel? — With the pytest-xdist plugin:
pytest -n auto.
How do you manage environments and dependencies in a Python project?
Section titled “How do you manage environments and dependencies in a Python project?”What it is
- venv — a folder, usually
.venv, with a link to the interpreter and its own packages. An activated environment takes overpythonandpipin the terminal. - pyproject.toml — the project file: name, version, required Python version, dependencies, tool settings. The counterpart of
.csproj.
Why
A global pip install puts the package into the whole system, and two projects needing different SQLAlchemy versions cannot coexist there. The system Python on Debian and Ubuntu refuses such installs outright: externally-managed-environment.
How to use
uv initcreatespyproject.toml.uv add sqlalchemyadds a dependency and creates.venvanduv.lock.uv run pytestruns a command inside the project environment..venvis not committed to git;uv.lockis.
Common mistake
Installing a package with pip install without adding it to pyproject.toml. The project will not run for a colleague or in CI.
Takeaway: one environment per project, dependencies in pyproject.toml, exact versions in the lock file.
How it works
| .NET | Python |
|---|---|
.csproj with PackageReference |
pyproject.toml, the [project] table |
packages.lock.json |
uv.lock, poetry.lock, requirements.txt with == |
dotnet restore |
uv sync |
| analyzers and compiler warnings | ruff, mypy or pyright |
dependenciesare direct dependencies with ranges. Development tools go into[dependency-groups](PEP 735) and do not end up in the package.- The lock pins exact versions and hashes of all transitive dependencies.
- Before running,
uv runupdates the lock and syncs the environment itself.
When to choose what
| Tool | When |
|---|---|
| uv | a new project: environments, lock, Python versions, tools |
| Poetry | the project already uses it; since 2.0 it understands [project] |
| pip-tools | the minimum: pip-compile pins requirements.in into requirements.txt |
Pitfalls
- A
requirements.txtwith ranges is not a lock: a build a month later gets different packages. ruff checkis the linter, replacing flake8 and isort;ruff formatis the formatter, replacing Black. CI needs both commands.- The interpreter does not check type annotations. Without mypy or pyright in CI, a type error survives until production.
Rule of thumb
For an application: ranges in pyproject.toml, the lock in git, uv sync --locked in CI.
Takeaway: pyproject.toml is the .csproj, the lock file is packages.lock.json, and uv is dotnet restore plus dotnet run in one.
Under the hood
- uv is a single Rust binary. Its lock is universal: the graph is resolved for all platforms at once, so one
uv.lockserves both Windows and a Linux image. - Packages live in a global cache and land in
.venvas hard links or copies, which is why an environment is created in seconds. uv python installinstalls the required interpreter version, anduvxruns a tool in a throwaway environment.
Cost
uv.lockis uv’s own format. uv can export the standardpylock.toml(PEP 751).- uv is still 0.x, and in March 2026 OpenAI announced it was acquiring Astral. The tools are promised to stay open source, but depending on a single vendor is a reason to stick to the standard
pyproject.tomltables.
Trade-offs
- Application or library. An application needs a lock. A library needs broad ranges: a narrow range breaks dependency resolution for its consumers.
- Type strictness.
mypy --strictpays off on new code; on legacy code it is enabled module by module. Astral’s ty is still in beta in 2026. - Ruff rules. A broad
selectcatches more, but each ruff upgrade brings new warnings, so its version is pinned in the lock.
How to check in production
- CI:
uv sync --locked,ruff check,ruff format --check, mypy, pytest. - The Docker image is built from the lock with
uv sync --locked --no-dev, not withpip installover ranges. - pip-audit or Dependabot scan the lock file for vulnerable versions.
Takeaway: reproducibility rests on the lock file and --locked in CI, while a standard pyproject.toml keeps you free to switch tools.
[project]name = "ads-parser"version = "0.1.0"requires-python = ">=3.12"dependencies = [ # direct dependencies with version ranges "sqlalchemy>=2.0", "alembic>=1.16", "psycopg[binary]>=3.2", "pydantic-settings>=2.6", "typer>=0.15",]
[dependency-groups]dev = ["pytest>=9", "ruff", "mypy"] # PEP 735: not included in the package
[tool.ruff]line-length = 100target-version = "py312"
[tool.ruff.lint]select = ["E", "F", "I", "B", "UP"] # pycodestyle, pyflakes, isort, bugbear, pyupgrade
[tool.mypy]strict = true
[tool.pytest] # native TOML table, pytest 9+testpaths = ["tests"]uv init ads-parser && cd ads-parser # pyproject.toml, .python-version, src/, gituv add sqlalchemy alembic "psycopg[binary]" # [project].dependencies, uv.lock and .venvuv add --dev pytest ruff mypy # the dev group in [dependency-groups]uv run pytest # lock and sync happen automatically first
# CI: fails if uv.lock does not match pyproject.tomluv sync --locked# error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided.uv run ruff check && uv run ruff format --check && uv run mypy src
uv export --format pylock.toml -o pylock.toml # the standard lock (PEP 751)- Why venv if there is
pip install --user? — Packages are still shared by all of the user’s projects, and versions conflict. - How does
uv sync --lockeddiffer from--frozen? —--lockedfails if the lock is stale;--frozenuses the lock as is without checking it againstpyproject.toml. - Does a library need a lock file? — It helps the library’s own CI but is never passed to consumers; package metadata carries only ranges.
- What does ruff replace? — The flake8 linters with plugins, isort and pyupgrade, and
ruff formatreplaces Black. - Why mypy if the code runs anyway? — Python does not check annotations; without a check in CI only production finds the type error.
How do you work with a database through SQLAlchemy 2?
Section titled “How do you work with a database through SQLAlchemy 2?”What it is
SQLAlchemy is an ORM for Python. Version 2.0 came out in 2023 and changed the API style; as of October 2026 the current version is 2.1.
How to use
- Create an
Engine:create_engine("postgresql+psycopg://user:pass@host/db"). There is one per application. - Describe a model: a class derived from
DeclarativeBasewith fields declared asMapped[int]andmapped_column(...). The typeMapped[str | None]gives a nullable column. - Open a session:
with Session(engine) as session:. - Read:
session.scalars(select(Ad).where(Ad.price > 1000)). - Write:
session.add(ad), thensession.commit().
Common mistake
Writing queries in the old session.query(Ad).filter(...) style. It still works but is considered legacy; new code uses select().
Takeaway: Engine is the connection, a model is a class with Mapped, Session works with objects, and select() builds queries.
How it works
- A
Sessiontakes a connection from theEnginepool on the first query and opens a transaction itself (autobegin). - Loaded objects go into the identity map: one row is one object, and a repeated
session.get(Ad, 1)does not hit the database. - Changes accumulate. Before a query (autoflush) and on
commit()the session flushes — it sendsINSERT,UPDATEandDELETEin dependency order. - After
commit()object attributes expire and are reloaded on next access.
SQLAlchemy and EF Core
| SQLAlchemy | EF Core |
|---|---|
Session |
DbContext |
session.commit() |
SaveChanges() |
select(Ad).where(...) |
LINQ Where |
joinedload |
Include |
selectinload |
Include with AsSplitQuery |
| lazy loading by default | only with UseLazyLoadingProxies |
Pitfalls
- N+1. Accessing
seller.adsin a loop runs a query per seller.selectinloadadds a second query withIN,joinedloaduses aJOIN; for collectionsselectinloadis the usual pick. joinedloadon a collection duplicates parent rows, so the result needs.unique(), otherwise you getInvalidRequestError.- An application-wide session bloats the identity map and keeps a transaction open. A session is per unit of work: a request, a job, a batch of listings.
Rule of thumb
One Engine per process, one Session per unit of work, session.begin() for an explicit transaction boundary, and the loading strategy in the query itself.
Takeaway: a Session is a DbContext with an identity map, except that lazy loading is on by default.
Under the hood
- Core is the SQL layer:
Table,select(),insert(),Connection. The ORM translates class-based queries into the same constructs, which is whyselect()is shared by both layers. - An
Engineholds aQueuePool: 5 connections by default plus up to 10 overflow.pool_pre_ping=Truechecks a connection before handing it out. AsyncSessionruns on top of the same synchronous core via greenlet. In 2.1 greenlet is no longer installed by default, so you need thesqlalchemy[asyncio]extra.
Cost
- An ORM object costs more than a row: identity map, change history, events. Thousands of listings insert faster through Core:
session.execute(insert(Ad), rows)or an upsert withpostgresql.insert(...).on_conflict_do_update(...). - An implicit query in async code — a lazy relationship or an expired attribute — fails with
MissingGreenlet. That is why async code usesexpire_on_commit=Falseand loads relationships eagerly.
Trade-offs
lazy="raise"on a relationship orraiseload("*")in a query turns an accidental N+1 into an exception. It is stricter, but every query has to request its relationships.expire_on_commit=Falseremoves extra queries after a commit, but an object may show stale data.- A single
AsyncSessionis not safe for concurrentasyncio.gathertasks: each task gets its own session.
How to check in production
echo=Trueor thesqlalchemy.enginelogger show every SQL statement, and tests count queries with thebefore_cursor_executeevent.- In PostgreSQL,
pg_stat_statementsreveals N+1 as thousands of identical short queries. - A
QueuePool limit ... reachederror is a sign of sessions that are never closed.
Takeaway: the ORM is a convenient unit of work on top of Core; hot paths drop down to Core, and lazy loading is kept under control with selectinload and raiseload.
from sqlalchemy import ForeignKey, create_engine, selectfrom sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, relationship
class Base(DeclarativeBase): pass
class Seller(Base): __tablename__ = "sellers" id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] # NOT NULL: the type has no None ads: Mapped[list["Ad"]] = relationship(back_populates="seller")
class Ad(Base): __tablename__ = "ads" id: Mapped[int] = mapped_column(primary_key=True) title: Mapped[str] price: Mapped[int | None] # NULL allowed seller_id: Mapped[int] = mapped_column(ForeignKey("sellers.id")) seller: Mapped[Seller] = relationship(back_populates="ads")
engine = create_engine("sqlite://") # in the project: "postgresql+psycopg://..."Base.metadata.create_all(engine) # in the project Alembic creates the schema# Continued: models and engine from the previous examplefrom sqlalchemy.orm import selectinload
with Session(engine) as session, session.begin(): # commit on exit, rollback on exception session.add( Seller(name="Olga", ads=[Ad(title="Bike", price=5000), Ad(title="Lamp", price=None)]) )
with Session(engine) as session: stmt = select(Ad).where(Ad.price > 1000).order_by(Ad.title) print([ad.title for ad in session.scalars(stmt)]) # ['Bike']
stmt = select(Seller).options(selectinload(Seller.ads)) # 2 queries instead of 1 + N for seller in session.scalars(stmt): print(seller.name, [ad.title for ad in seller.ads]) # Olga ['Bike', 'Lamp']
seller = session.get(Seller, 1) # already in the identity map — no query seller.name = "Olga K." # the change is tracked session.commit() # flush: UPDATE sellers SET name=... and COMMIT# Models and imports from the previous examples; packages: sqlalchemy[asyncio], aiosqliteimport asyncio
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
async def main() -> None: engine = create_async_engine("sqlite+aiosqlite://") # in a project: "postgresql+asyncpg://..." async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all)
new_session = async_sessionmaker(engine, expire_on_commit=False) async with new_session() as session, session.begin(): session.add(Seller(name="Olga", ads=[Ad(title="Bike", price=5000)]))
async with new_session() as session: stmt = select(Seller).options(selectinload(Seller.ads)) # relationships — eagerly only seller = await session.scalar(stmt) print([ad.title for ad in seller.ads]) # ['Bike']; without selectinload — MissingGreenlet await engine.dispose()
asyncio.run(main())- How does
session.flush()differ fromcommit()? — flush sends SQL inside the current transaction;commit()flushes and commits the transaction. - Where does the extra
SELECTaftercommit()come from? —expire_on_commit=True: attributes have expired and are reloaded on access. selectinloadorjoinedload? —selectinloadfor collections,joinedloadfor many-to-one relationships.- What does
MissingGreenletmean? — Implicit I/O in async code: a lazy relationship or an expired attribute; load relationships eagerly. - When do you drop down to Core? — Bulk inserts and upserts, reporting queries — anywhere objects and change tracking are not needed.
How do you handle migrations and configuration in production with Alembic and pydantic-settings?
Section titled “How do you handle migrations and configuration in production with Alembic and pydantic-settings?”What it is
- A migration is a file that moves the database schema to the next version: add a table, a column, an index.
- Alembic is the migration tool from the SQLAlchemy authors. Migrations form a chain: each one knows the previous one.
- pydantic-settings is a settings class whose fields are filled from environment variables and a
.envfile.
How to use
alembic init migrationscreates the migrations directory andenv.py.- In
env.pysettarget_metadata = Base.metadata— that is how Alembic sees the models. - After changing a model, run
alembic revision --autogenerate -m "add price". - Read the generated file and apply it:
alembic upgrade head. - In
env.py, take the connection string fromSettingsinstead of writing it intoalembic.ini.
Common mistake
Committing a .env with a password. The repository gets a .env.example without secrets, and .env goes into .gitignore.
Takeaway: the schema changes through Alembic migrations, and settings and secrets come from the environment.
How it works
- A revision is a file with
revision,down_revision,upgrade()anddowngrade(). Thedown_revisionchain sets the order. alembic upgrade headreads the version fromalembic_versionand runs the missing revisions.- Autogenerate compares
Base.metadatawith the database and writes a draft:op.add_column,op.create_index.
What autogenerate misses
| Change | What you get |
|---|---|
| Renaming a table or column | a drop and an add — the data is lost |
| An unnamed constraint | not tracked |
server_default |
not compared without compare_server_default |
How settings are read
Sources by descending priority: constructor arguments, environment variables, .env, the secrets directory, defaults. Variable names are case-insensitive, env_prefix separates your variables from everyone else’s, and strings are coerced to the field types.
Pitfalls
- An unnamed constraint gets its name from the database, and autogenerate cannot generate its drop. Set a
naming_conventiononMetaData. - The default is
extra="forbid": an unknown line in.env, even someone else’s without the prefix, fails startup withValidationError. - A
downgrade()withdrop_columndoes not bring data back; it deletes it.
Rule of thumb
Read every generated migration and run upgrade, downgrade and upgrade again in CI.
Takeaway: autogenerate writes a draft for a human to read, and settings are a typed class validated at startup.
Under the hood
- Revisions form a graph. Two branches from one revision give two heads, and
upgrade headfails withMultiple head revisions are present.alembic merge headscreates a revision with two parents indown_revision. - DDL in PostgreSQL is transactional: by default the whole
upgradeis one transaction.CREATE INDEX CONCURRENTLYis not allowed inside a transaction, so it is wrapped inop.get_context().autocommit_block(). alembic upgrade head --sqlprints the SQL without connecting — for review or manual application.
Trade-offs
- Data in migrations. Small fixes use
op.executeover ansa.table()snapshot: six months later the ORM model will no longer match this revision’s schema. A backfill of a large table is a separate batched job. - Online changes. A rename under load is expand/contract over several releases, covered in Design and operations.
- When to run. Alembic has no protection against concurrent runs, so migrations are applied as a separate deployment step, not on startup of every replica.
Secrets and CLI
SecretStrmasks the value inreprand logs; the real one comes fromget_secret_value().- In production secrets arrive as environment variables or files in
secrets_dir, not from.env. The environment always wins over.env. - Typer CLI commands are functions with type annotations. Settings are read inside the command so that
--helpworks without an environment.
How to check in production
- CI: a single head in
alembic heads, an emptyalembic check, anupgrade—downgrade—upgradecycle. - Database version:
SELECT version_num FROM alembic_versionagainst the release’s head.
Takeaway: a migration is code for production data: review, named constraints, a single head; settings are validated at startup, and secrets come from the environment.
"""add ads.price_uah
Revision ID: b7e1f2a3c4d5Revises: 4c288caa5ad9"""
import sqlalchemy as safrom alembic import op
revision = "b7e1f2a3c4d5"down_revision = "4c288caa5ad9"
# A table snapshot as of this migration, not the ORM model: the model will keep changingads = sa.table("ads", sa.column("price", sa.Integer), sa.column("price_uah", sa.Integer))
def upgrade() -> None: op.add_column("ads", sa.Column("price_uah", sa.Integer(), nullable=True)) # expand op.execute(ads.update().values(price_uah=ads.c.price)) # data; large tables — in batches # PostgreSQL: CREATE INDEX CONCURRENTLY cannot run inside a transaction with op.get_context().autocommit_block(): op.create_index("ix_ads_price_uah", "ads", ["price_uah"], postgresql_concurrently=True)
def downgrade() -> None: with op.get_context().autocommit_block(): op.drop_index("ix_ads_price_uah", table_name="ads", postgresql_concurrently=True) op.drop_column("ads", "price_uah")For PostgreSQL, alembic upgrade head --sql shows a COMMIT before CREATE INDEX CONCURRENTLY and a new transaction after it.
from typing import Annotated
import typerfrom pydantic import SecretStrfrom pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings): model_config = SettingsConfigDict(env_prefix="PARSER_", env_file=".env")
db_url: SecretStr # required: without it — ValidationError at startup pages: int = 5 # PARSER_PAGES=10 → 10: the string from the environment is coerced to int
app = typer.Typer()
@app.command()def crawl(pages: Annotated[int | None, typer.Option(help="Override PARSER_PAGES")] = None) -> None: settings = Settings() # environment + .env, validated once when the command starts typer.echo(f"pages={pages or settings.pages} db={settings.db_url}")
if __name__ == "__main__": app()
# $ PARSER_DB_URL=postgresql+psycopg://app:pw@db/ads python cli.py --pages 2# pages=2 db=**********- Why did autogenerate turn a rename into a column drop? — It compares names only; renames are written by hand with
op.alter_column(..., new_column_name=...). - What do you do with two heads after merging branches? —
alembic merge heads, plus a CI check that there is exactly one head. - Why not import ORM models in a migration? — The model reflects the current schema, the migration the schema at its own point in time; within months they diverge.
- What is risky about
config.set_main_option("sqlalchemy.url", url)? — The value goes through ConfigParser interpolation, so a%in the password must be doubled; it is simpler to create the engine inenv.pyfrom settings. - Which wins: an environment variable or
.env? — The environment variable;.envis a fallback for development.