Ana içeriğe geç
Kesit Python
İleri · Veritabanı

SQLAlchemy 2.0: Core, ORM, ilişkiler ve transaction

Engine ve bağlı parametrelerle güvenli SQL çalıştırır, Core ile sorgu kurar, Mapped/mapped_column ile ORM modelleri ve ilişkiler tanımlar, Session ile CRUD yapar, N+1 problemini eager loading ile çözer, transaction ve Alembic migration mantığını öğrenirsin.

Uygulamada aç, 27 alıştırmayı çözÜcretsiz, hesap gerekmez, kurulum yok.

Bu sayfa dersin okuma sürümüdür. Metin bir kutuyu çalıştırmanı ya da adıma geçmeni söylüyorsa dersi uygulamada aç.

Neden önemli?

sqlite3 ile Python dersinde veritabanına doğrudan SQL metni gönderdin. Küçük betiklerde bu yeterlidir. Ama gerçek bir uygulamada onlarca tablo, yüzlerce sorgu, farklı veritabanları (geliştirmede SQLite, üretimde PostgreSQL) ve iş nesneleri (müşteri, sipariş, hesap) vardır. Her sorgunun sonucunu elle sözlüğe çevirmek, her tabloda aynı INSERT kalıbını tekrar yazmak, bağlantıları ve transaction'ları doğru yönetmek zorlaşır.

SQLAlchemy, Python dünyasının en yaygın veritabanı araç takımıdır. İki katmanı vardır: Core (SQL'i Python ifadeleriyle kuran, veritabanından bağımsız katman) ve ORM (Object Relational Mapper: tablo satırlarını Python nesnelerine eşleyen katman). Flask, FastAPI, Airflow ve pandas (read_sql) gibi araçların hepsi SQLAlchemy ile konuşur. Bu derste 2.0 sürümünün modern API'sini kullanacağız; eski session.query(...) tarzını da yerinde göreceksin.

Kesit SQL'e köprüKesit SQL'de öğrendiğin SELECT, WHERE, JOIN, GROUP BY ve transaction kavramlarının hepsi burada aynen geçerli. SQLAlchemy SQL'i bilmeyi gereksiz kılmaz; tersine, ORM'in ürettiği SQL'i okuyabilen geliştirici performans sorunlarını çok daha hızlı çözer.
KatmanNe yapar?Örnek
DBAPI sürücüsüVeritabanıyla gerçekten konuşursqlite3, psycopg, pymysql
EngineBağlantı havuzu ve SQL lehçesi (dialect)create_engine("sqlite+pysqlite:///:memory:")
CoreSQL'i Python ifadesiyle kurarselect(siparis).where(siparis.c.tutar > 100)
ORMSatır ↔ nesne eşlemesi, Session, ilişkilersession.scalars(select(Musteri))

Engine, bağlantı ve text()

Her şey bir Engine ile başlar. Bağlantı adresi (URL) lehçe+sürücü://kullanıcı:şifre@sunucu/veritabanı biçimindedir. Bellek içi SQLite için sqlite+pysqlite:///:memory: yazarsın; PostgreSQL'e geçmek çoğu zaman yalnızca bu adresi değiştirmektir.

from sqlalchemy import create_engine, text

engine = create_engine("sqlite+pysqlite:///:memory:")

# engine.begin(): blok başarıyla biterse COMMIT, hata olursa ROLLBACK
with engine.begin() as conn:
    conn.execute(text("CREATE TABLE siparis (id INTEGER PRIMARY KEY, sehir TEXT, tutar REAL)"))
    conn.execute(
        text("INSERT INTO siparis (sehir, tutar) VALUES (:sehir, :tutar)"),
        [{"sehir": "İzmir", "tutar": 120.0}, {"sehir": "Ankara", "tutar": 300.0},
         {"sehir": "İzmir", "tutar": 80.0}],
    )

# engine.connect(): yalnız okuma; yazarsan conn.commit() gerekir
with engine.connect() as conn:
    sonuc = conn.execute(text("SELECT sehir, SUM(tutar) AS ciro FROM siparis "
                              "WHERE tutar >= :alt GROUP BY sehir ORDER BY ciro DESC"),
                         {"alt": 50})
    for satir in sonuc:
        print(satir.sehir, satir.ciro)       # satıra adla erişim
    print(conn.execute(text("SELECT COUNT(*) FROM siparis")).scalar())

:sehir gibi adlandırılmış yer tutucular bağlı parametrelerdir (bound parameters). Değerler SQL metnine yapıştırılmaz, sürücüye ayrıca gönderilir; bu yüzden SQL enjeksiyonu (SQL injection) mümkün değildir. Bir liste sözlük verdiğinde SQLAlchemy işlemi executemany olarak toplu çalıştırır.

f-string ile SQL yazmatext(f"... WHERE sehir = '{sehir}'") yazmak sqlite3 dersindeki enjeksiyon açığının aynısıdır; text() sihirli bir koruma sağlamaz. Kullanıcıdan gelen her değer :ad yer tutucusuyla gitmeli.

Core: tabloyu Python nesnesiyle tanımlamak

Core'da tablo bir Table nesnesidir. Sorgular select, insert, update, delete fonksiyonlarıyla kurulur ve SQLAlchemy bunları kullandığın veritabanının lehçesine çevirir. Sorguyu print edersen üretilen SQL'i görürsün.

from sqlalchemy import (create_engine, MetaData, Table, Column, Integer, String,
                        Float, select, insert, func)

engine = create_engine("sqlite+pysqlite:///:memory:")
meta = MetaData()
urun = Table("urun", meta,
             Column("id", Integer, primary_key=True),
             Column("ad", String(80), nullable=False),
             Column("kategori", String(40)),
             Column("fiyat", Float))
meta.create_all(engine)

with engine.begin() as conn:
    conn.execute(insert(urun), [
        {"ad": "Çay", "kategori": "İçecek", "fiyat": 85.5},
        {"ad": "Kahve", "kategori": "İçecek", "fiyat": 240.0},
        {"ad": "Bisküvi", "kategori": "Atıştırmalık", "fiyat": 25.0},
    ])

sorgu = (select(urun.c.kategori, func.count().label("adet"), func.avg(urun.c.fiyat).label("ort"))
         .group_by(urun.c.kategori)
         .order_by(urun.c.kategori))
print(sorgu)                                 # üretilen SQL
with engine.connect() as conn:
    for k, adet, ort in conn.execute(sorgu):
        print(k, adet, round(ort, 2))

ORM: modeller, Mapped ve mapped_column

ORM'de her tablo bir Python sınıfıdır. 2.0 stilinde sınıflar DeclarativeBase'ten türeyen bir Base sınıfından türetilir; sütunlar Mapped[tip] tip ipucuyla (type hint) bildirilir. Mapped[str] NOT NULL, Mapped[str | None] boş bırakılabilir sütun demektir. Ek ayarlar (birincil anahtar, varsayılan, benzersizlik) mapped_column ile verilir. Metaprogramlama dersinde kurduğun mini model katmanını hatırla: SQLAlchemy de sınıf gövdesini inceleyip tabloyu böyle çıkarır.

from sqlalchemy import create_engine, select, func, ForeignKey
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, Session


class Base(DeclarativeBase):
    pass


class Musteri(Base):
    __tablename__ = "musteri"
    id: Mapped[int] = mapped_column(primary_key=True)
    ad: Mapped[str]
    sehir: Mapped[str]
    siparisler: Mapped[list["Siparis"]] = relationship(back_populates="musteri")


class Siparis(Base):
    __tablename__ = "siparis"
    id: Mapped[int] = mapped_column(primary_key=True)
    tutar: Mapped[float]
    musteri_id: Mapped[int] = mapped_column(ForeignKey("musteri.id"))
    musteri: Mapped["Musteri"] = relationship(back_populates="siparisler")


engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(engine)            # CREATE TABLE ifadelerini üretir
print(Musteri.__table__.c.keys(), Siparis.__table__.c.keys())
print(repr(Siparis.__table__.c.musteri_id.type), Musteri.__table__.c.ad.nullable)

relationship sütun değil, Python tarafındaki bağlantıdır: musteri.siparisler bir liste, siparis.musteri tek bir nesne verir. back_populates iki tarafı birbirine bağlar; birine eklediğin diğerinde de görünür. Asıl ilişkiyi veritabanında taşıyan şey ForeignKey("musteri.id") sütunudur.

Session ve CRUD

Session, ORM'in çalışma birimidir (unit of work): nesneleri izler, değişiklikleri toplar ve commit() anında doğru sırada INSERT/UPDATE/DELETE üretir. Aynı oturumda aynı birincil anahtarlı satır her zaman aynı Python nesnesidir (identity map).

from sqlalchemy import create_engine, select, func, ForeignKey
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, Session


class Base(DeclarativeBase):
    pass


class Musteri(Base):
    __tablename__ = "musteri"
    id: Mapped[int] = mapped_column(primary_key=True)
    ad: Mapped[str]
    sehir: Mapped[str]
    siparisler: Mapped[list["Siparis"]] = relationship(back_populates="musteri")


class Siparis(Base):
    __tablename__ = "siparis"
    id: Mapped[int] = mapped_column(primary_key=True)
    tutar: Mapped[float]
    musteri_id: Mapped[int] = mapped_column(ForeignKey("musteri.id"))
    musteri: Mapped["Musteri"] = relationship(back_populates="siparisler")


engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(engine)

with Session(engine) as s:
    # CREATE
    ayse = Musteri(ad="Ayşe", sehir="İzmir")
    ayse.siparisler.append(Siparis(tutar=120.0))
    s.add_all([ayse, Musteri(ad="Mehmet", sehir="Ankara")])
    s.commit()
    print("id:", ayse.id)

with Session(engine) as s:
    # READ
    m = s.get(Musteri, 1)                              # birincil anahtarla
    izmirliler = s.scalars(select(Musteri).where(Musteri.sehir == "İzmir")).all()
    print(m.ad, [x.ad for x in izmirliler])
    # UPDATE: nesneyi değiştir, commit et
    m.sehir = "Manisa"
    # DELETE
    s.delete(s.scalars(select(Musteri).where(Musteri.ad == "Mehmet")).one())
    s.commit()
    print(s.scalars(select(Musteri.ad)).all(), m.sehir)
    print(s.scalar(select(func.count()).select_from(Siparis)))
Amaç2.0 stiliEski (1.x) stil
Birincil anahtarla getirs.get(Musteri, 1)s.query(Musteri).get(1)
Filtreli listes.scalars(select(Musteri).where(...)).all()s.query(Musteri).filter(...).all()
Tek sonuç (yoksa hata).one().one()
Tek sonuç ya da None.first() / .one_or_none()aynı
Tek değer (sayı, toplam)s.scalar(select(func.count())...)s.query(func.count(...)).scalar()

scalars() her satırın ilk sütununu (burada model nesnesini) verir. Birden çok sütun seçtiğinde (select(Musteri.ad, Musteri.sehir)) s.execute(...) ile satır demetleri alırsın.

Lazy loading ve N+1 problemi

Varsayılan olarak ilişkiler tembel (lazy) yüklenir: musteri.siparisler'e ilk eriştiğinde ayrı bir SELECT çalışır. 100 müşteriyi listeleyip her birinin siparişlerine dokunursan 1 + 100 sorgu atarsın. Buna N+1 problemi denir ve ORM kullanan uygulamalardaki en yaygın performans sorunudur. Çözüm, ilişkiyi istekli (eager) yüklemektir.

from sqlalchemy import create_engine, select, func, ForeignKey
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, Session


class Base(DeclarativeBase):
    pass


class Musteri(Base):
    __tablename__ = "musteri"
    id: Mapped[int] = mapped_column(primary_key=True)
    ad: Mapped[str]
    sehir: Mapped[str]
    siparisler: Mapped[list["Siparis"]] = relationship(back_populates="musteri")


class Siparis(Base):
    __tablename__ = "siparis"
    id: Mapped[int] = mapped_column(primary_key=True)
    tutar: Mapped[float]
    musteri_id: Mapped[int] = mapped_column(ForeignKey("musteri.id"))
    musteri: Mapped["Musteri"] = relationship(back_populates="siparisler")

from sqlalchemy import event
from sqlalchemy.orm import selectinload, joinedload

engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(engine)
with Session(engine) as s:
    s.add_all([
        Musteri(ad="Ayşe", sehir="İzmir", siparisler=[Siparis(tutar=120.0), Siparis(tutar=80.0)]),
        Musteri(ad="Mehmet", sehir="Ankara", siparisler=[Siparis(tutar=300.0)]),
        Musteri(ad="Zeynep", sehir="İzmir", siparisler=[Siparis(tutar=50.0)]),
        Musteri(ad="Can", sehir="Bursa"),
    ])
    s.commit()

sorgular = []
event.listen(engine, "before_cursor_execute",
             lambda conn, cur, sql, params, ctx, many: sorgular.append(sql))

def rapor(secenek=None):
    sorgular.clear()
    with Session(engine) as s:
        q = select(Musteri).order_by(Musteri.id)
        if secenek is not None:
            q = q.options(secenek)
        # joinedload koleksiyonda satırları çoğaltır; unique() tekrarları birleştirir
        satirlar = [(m.ad, len(m.siparisler)) for m in s.scalars(q).unique()]
    return satirlar, len(sorgular)

print("lazy        :", rapor())
print("selectinload:", rapor(selectinload(Musteri.siparisler)))
print("joinedload  :", rapor(joinedload(Musteri.siparisler))[1], "sorgu")
StratejiNasıl yükler?Ne zaman?
lazyload (varsayılan)Erişimde ayrı SELECTİlişkiye nadiren dokunuluyorsa
selectinloadİkinci bir SELECT ... WHERE id IN (...)Bire-çok koleksiyonlar için çoğu zaman en iyisi
joinedloadAynı sorguda LEFT OUTER JOINÇoka-bir (sipariş → müşteri) ilişkiler
raiseloadErişimde hata fırlatırGizli N+1'leri testte yakalamak için

joinedload bire-çok ilişkide JOIN yüzünden her müşteriyi sipariş sayısı kadar satırda döndürür; bu yüzden 2.0'da sonucu .unique() ile tekilleştirmen zorunludur. selectinload'da böyle bir gereklilik yoktur; koleksiyonlar için çoğu zaman onu tercih etmen bundandır.

Transaction: ya hep ya hiç

Session her zaman bir transaction içindedir. commit() kalıcı yapar, rollback() geri alır. with Session(engine) as s, s.begin(): kalıbı blok başarıyla biterse commit, istisna olursa rollback yapar. Ilgaz Bank'ta havale gibi birden çok satırı değiştiren işlemler mutlaka tek transaction'da olmalı.

from sqlalchemy import create_engine, select, CheckConstraint
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session

class Base(DeclarativeBase):
    pass

class Hesap(Base):
    __tablename__ = "hesap"
    no: Mapped[str] = mapped_column(primary_key=True)
    bakiye: Mapped[float] = mapped_column(CheckConstraint("bakiye >= 0"))

engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(engine)
with Session(engine) as s, s.begin():
    s.add_all([Hesap(no="TR-1", bakiye=1000), Hesap(no="TR-2", bakiye=50)])

def havale(gonderen, alici, tutar):
    with Session(engine) as s, s.begin():          # hata olursa otomatik rollback
        g, a = s.get(Hesap, gonderen), s.get(Hesap, alici)
        a.bakiye += tutar
        g.bakiye -= tutar
        if g.bakiye < 0:
            raise ValueError("Yetersiz bakiye")

havale("TR-1", "TR-2", 300)
try:
    havale("TR-2", "TR-1", 9999)
except ValueError as h:
    print("İptal:", h)
with Session(engine) as s:
    print([(h.no, h.bakiye) for h in s.scalars(select(Hesap).order_by(Hesap.no))])

İkinci havalede önce alıcıya para eklendi, sonra hata fırladı; ama rollback sayesinde hiçbir değişiklik kalıcı olmadı. Ayrıca veritabanındaki CHECK kısıtı ikinci bir savunma hattıdır: Python kodunda hata olsa bile negatif bakiye kaydedilemez.

Alembic ile migration (gösterim)

create_all yalnız eksik tabloları oluşturur; mevcut tabloya sütun eklemez. Üretimde şema değişiklikleri Alembic ile sürümlenir: her değişiklik bir migration dosyasıdır, git'te saklanır, tüm ortamlara aynı sırayla uygulanır.

pip install alembic
alembic init migrations              # migrations/ klasörü ve alembic.ini
# alembic.ini: sqlalchemy.url = postgresql+psycopg://...
# migrations/env.py: target_metadata = Base.metadata
alembic revision --autogenerate -m "musteriye telefon ekle"
alembic upgrade head                 # bekleyen tüm migration'ları uygula
alembic downgrade -1                 # son migration'ı geri al
# migrations/versions/3f2a_musteriye_telefon_ekle.py (otomatik üretilir, gözden geçirilir)
def upgrade():
    op.add_column("musteri", sa.Column("telefon", sa.String(20), nullable=True))

def downgrade():
    op.drop_column("musteri", "telefon")
Kendi bilgisayarında deneAlembic komut satırı aracıdır ve dosya sistemi ister; tarayıcıda çalışmaz. Yerelde pip install sqlalchemy alembic ile kurup bir SQLite dosyası (sqlite:///magaza.db) üzerinde deneyebilirsin. --autogenerate çıktısını her zaman gözle kontrol et: sütun yeniden adlandırmayı "sil + ekle" olarak algılayıp veri kaybettirebilir.

Ham SQL mi, ORM mi?

DurumTercihNeden
İş nesneleri üzerinde CRUD, ilişkilerORMOkunur kod, identity map, unit of work
Rapor, büyük GROUP BY, pencere fonksiyonları (window function)Core ya da text()Nesneye çevirmeye gerek yok; SQL daha açık
Milyonlarca satır toplu yüklemeCore insert() + liste, ya da veritabanının COPY aracıNesne başına maliyet yok
Veritabanına özgü özellik (JSONB, CTE)Core/textLehçe özelliklerine tam erişim
Analiz için DataFramepd.read_sql(sorgu, engine)Doğrudan tabloya
Gerçek işteAnadolu Market'in sipariş paneli yavaşladığında ilk bakılan şey, sayfa başına kaç SQL sorgusu atıldığıdır. Tipik bulgu: sipariş listesi 50 sipariş gösteriyor, şablon her siparişte siparis.musteri.ad ve siparis.kalemler'e dokunuyor ve sayfa 101 sorgu atıyor. Sorguya selectinload(Siparis.kalemler) ve joinedload(Siparis.musteri) eklemek sayfayı 2-3 sorguya indirir. Ekipler bunu kalıcı kılmak için testlerde sorgu sayısını sayan bir yardımcı (bu dersin meydan okuma görevi) kullanır ve sınırı aşan değişikliği CI'da reddeder.
Derinleş

Derste Alembic'i yalnız komutlarıyla gördün ve async'e bir paragrafla değindik. Bu bölüm ikisini de gerçek iş akışına taşıyor: bir migration dosyasının içi, veri taşıyan migration, canlı sistemde kesintisiz şema değişikliği ve FastAPI gibi async çatılarda SQLAlchemy.

Bir migration dosyasının anatomisi

alembic revision --autogenerate -m "musteriye telefon ekle" komutu migrations/versions/ altında bir Python dosyası üretir. Alembic sürümleri bir zincir olarak tutar: her dosya kendi kimliğini (revision) ve bir öncekini (down_revision) bilir. Veritabanında ise alembic_version adlı tek satırlık bir tablo, şemanın şu an hangi halkada olduğunu saklar.

"""musteriye telefon ekle"""
from alembic import op
import sqlalchemy as sa

revision = "b7c2e1"
down_revision = "a41f09"      # bir önceki halka; ilk migration'da None

def upgrade() -> None:
    op.add_column("musteri", sa.Column("telefon", sa.String(20), nullable=True))
    op.create_index("ix_musteri_telefon", "musteri", ["telefon"])

def downgrade() -> None:
    op.drop_index("ix_musteri_telefon", table_name="musteri")
    op.drop_column("musteri", "telefon")

Günlük akış şöyledir: modeli değiştir, revision --autogenerate çalıştır, üretilen dosyayı satır satır oku (autogenerate bir taslaktır), yerelde upgrade head ve hemen ardından downgrade -1 ile geri alınabildiğini dene, dosyayı modelle aynı commit'e koy. CI'da boş bir veritabanına alembic upgrade head çalıştırmak, zincirin kırılmadığını kanıtlar. İki geliştirici aynı halkadan dal açarsa "iki head" oluşur; alembic heads bunu gösterir, alembic merge ile birleştirilir.

autogenerate şunu yakalarŞunu yakalamaz ya da yanlış anlar
Tablo ve sütun ekleme/silmeSütun yeniden adlandırma (sil + ekle sanır, veri gider)
nullable değişikliği, indeks, yabancı anahtarTablo adı değişikliği, CHECK kısıtlarının çoğu
Sütun türü (compare_type açıksa)Veri dönüşümü: eski değerleri yeni sütuna taşımak

Veri migration'ı

Şema değişikliği bazen veriyi de taşımayı gerektirir: ad_soyad sütununu ad ve soyad'a bölmek, şehir adından şehir kodu türetmek gibi. Bunu migration içinde op.execute ile SQL olarak yaz; ORM modellerini import etme, çünkü model ileride değişir ve eski migration bozulur. Büyük tablolarda tek dev UPDATE yerine parti parti güncelle ki kilitler kısa sürsün. SQLite ALTER TABLE desteği sınırlı olduğundan Alembic orada op.batch_alter_table ile tabloyu yeniden kurar.

Aşağıdaki çalıştırılabilir örnek Alembic'in yaptığını sqlite3 ile taklit ediyor: sürüm tablosu, sırayla uygulanan halkalar ve bir veri migration'ı. Veri, Anadolu Market'in gerçek customers tablosu.

import veri
conn = veri.baglan("anadolu_market", tablolar=["customers"])
conn.execute("CREATE TABLE alembic_version (version_num TEXT)")
conn.execute("INSERT INTO alembic_version VALUES ('base')")

KODLAR = {"İstanbul": 34, "Ankara": 6, "İzmir": 35, "Bursa": 16}

def m001_upgrade(c):
    c.execute("ALTER TABLE customers ADD COLUMN plaka INTEGER")    # 1) boş (nullable) sütun

def m002_upgrade(c):                                              # 2) veri migration'ı
    for sehir, kod in KODLAR.items():
        c.execute("UPDATE customers SET plaka = ? WHERE city = ?", (kod, sehir))

zincir = [("m001", m001_upgrade), ("m002", m002_upgrade)]
for rev, fn in zincir:
    with conn:                                   # her halka kendi transaction'ında
        fn(conn)
        conn.execute("UPDATE alembic_version SET version_num = ?", (rev,))

print(conn.execute("SELECT version_num FROM alembic_version").fetchone()[0])
print(conn.execute("SELECT plaka, COUNT(*) FROM customers GROUP BY plaka ORDER BY plaka").fetchall())

None grubu, kodu tabloda olmayan şehirleri ve şehri boş müşterileri gösteriyor. Gerçek migration'da bu sayıyı loglar, sıfır değilse kararı veri sahibine bırakırsın.

Sıfır kesintili migration: genişlet ve daralt

Canlıda dağıtım sırasında eski ve yeni kod bir süre aynı anda çalışır (sırayla güncellenen konteynerler). Bu yüzden her şema değişikliği hem eski hem yeni kodla uyumlu olmalı. Kalıbın adı genişlet ve daralt (expand and contract):

  1. Genişlet: yeni sütunu nullable ya da varsayılanlı ekle. Eski kod onu görmez, bozulmaz.
  2. Çift yaz: yeni kod hem eski hem yeni sütuna yazar.
  3. Geri doldur (backfill): eski satırları partiler hâlinde doldur.
  4. Geçiş: okumalar yeni sütuna geçer; doğrulama yap.
  5. Daralt: ayrı bir sürümde NOT NULL ekle, eski sütunu sil.
Tek adımda yeniden adlandırmaALTER TABLE ... RENAME COLUMN şemayı bir anda değiştirir; o sırada çalışan eski kod eski adı arar ve hata verir. Kesinti istemiyorsan yeniden adlandırmayı yukarıdaki beş adıma böl. PostgreSQL'de büyük tabloya indeks eklerken de CREATE INDEX CONCURRENTLY kullan (Alembic'te postgresql_concurrently=True, transaction dışında).

Async SQLAlchemy

FastAPI gibi async çatılarda veritabanı çağrısı beklenirken olay döngüsü başka istekleri işleyebilsin diye async sürücü kullanılır: PostgreSQL için asyncpg, SQLite için aiosqlite. API, bildiğin 2.0 API'sinin await'li hâlidir.

from sqlalchemy import select
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from sqlalchemy.orm import selectinload

engine = create_async_engine("postgresql+asyncpg://app:gizli@db/market", pool_size=10)
Oturum = async_sessionmaker(engine, expire_on_commit=False)

async def musteri_siparisleri(musteri_id: int):
    async with Oturum() as s:
        sorgu = (select(Musteri)
                 .where(Musteri.id == musteri_id)
                 .options(selectinload(Musteri.siparisler)))   # tembel yükleme yok: önceden yükle
        m = (await s.execute(sorgu)).scalar_one()
        return [x.tutar for x in m.siparisler]

async def tablolari_kur():
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)   # senkron API'yi async içinde çalıştır
Kendi bilgisayarında denepip install "sqlalchemy[asyncio]" aiosqlite alembic ile kur; adres olarak sqlite+aiosqlite:///magaza.db kullan. Tarayıcıda async sürücü yok; aşağıdaki alıştırmalar kalıbı asyncio ve sahte oturumla çalıştırıyor.

Üç kural akılda kalsın. Bir: AsyncSession eşzamanlı görevler arasında paylaşılmaz; asyncio.gather ile paralel sorgu atacaksan her göreve ayrı oturum aç. İki: async'te tembel yükleme MissingGreenlet hatası verir; ilişkileri selectinload ile önceden yükle. Üç: expire_on_commit=False vermezsen commit sonrası özniteliğe erişmek gizli bir sorgu tetikler ve yine aynı hataya düşersin.

Gerçek işteEkiplerde migration dosyası kod incelemesinin (code review) en dikkatli bakılan parçasıdır. İncelemede sorulan sorular hep aynıdır: downgrade gerçekten çalışıyor mu, büyük tabloyu kilitler mi, eski kod bu şemayla çalışmaya devam eder mi, veri kaybı var mı?
Uzmanlaş

Session'ın ömrü

Session uzun yaşayan küresel bir nesne olmamalı. Web uygulamalarında kural "istek başına bir session"dır: istek gelince açılır, yanıt dönünce kapanır. Flask-SQLAlchemy ve FastAPI'nin Depends(get_db) kalıbı bunu senin için yapar. sessionmaker(engine) ile ayarları bir kez belirleyip with SessionLocal() as s: şeklinde kullanırsın.

expire_on_commit

Varsayılan olarak commit'ten sonra nesnelerin öznitelikleri "süresi dolmuş" sayılır ve bir sonraki erişimde yeniden okunur. Session kapandıktan sonra böyle bir özniteliğe erişirsen DetachedInstanceError alırsın. Nesneyi session dışında kullanacaksan ya gereken alanları kapanmadan oku ya da Session(engine, expire_on_commit=False) kullan.

Bağlantı havuzu ve async

Engine bağlantıları havuzda (pool) tutar; uygulama başına bir engine oluşturulur, her istekte yeniden oluşturulmaz. pool_size, max_overflow ve pool_pre_ping=True üretim ayarlarıdır. FastAPI gibi async çatılarda sqlalchemy.ext.asyncio içindeki create_async_engine ve AsyncSession kullanılır; async ortamda tembel yükleme yasaktır, bu yüzden eager loading orada zorunluluk hâline gelir.

Alıştırmalara geç (27)