Ana içeriğe geç
Kesit Python
İleri · Veri analizi ve bilimi

polars, DuckDB ve Parquet: büyük veriye tek makinede

pandas'ın bellek ve tek çekirdek sınırını aşarsın: chunksize ile parçalı okuma, polars ifadeleri ve lazy API, DuckDB ile DataFrame'e SQL, Arrow/Parquet ve PySpark'a giriş; veri boyutuna göre araç seçersin.

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ç.

pandas yetmediğinde

Anadolu Market'in sipariş tablosu ilk yıl 200 bin satırdı; pandas ile saniyeler içinde gruplayıp raporladın (bunu pandas-groupby dersinde gördün). Üç yıl sonra tablo 400 milyon satır, CSV dosyası 60 GB. Dizüstü bilgisayarında 16 GB bellek var. pd.read_csv birkaç dakika diskle boğuştuktan sonra MemoryError verir ya da makine kilitlenir. Sorun senin kodunda değil, aracın tasarımında: pandas veriyi tamamen belleğe (eager, in-memory) yükler, çoğu işlemi tek çekirdekte çalıştırır ve ara sonuçlar için sık sık kopya üretir. Kabaca söylersek pandas'ın rahat çalıştığı sınır, verinin bellekte kapladığı alanın RAM'in beşte biri ile üçte biri arasında olmasıdır.

Bu derste o sınırı aşmanın dört yolunu öğreneceksin: veriyi parça parça okumak (chunksize), çok çekirdekli ve tembel çalışan polars, DataFrame'leri doğrudan SQL ile sorgulayan gömülü veritabanı DuckDB ve hepsinin ortak dili olan Apache Arrow / Parquet. Son olarak veri tek makineye sığmadığında devreye giren PySpark'ı tanıyacak ve "hangi boyutta hangi araç" kararını vereceksin.

1. Parça parça okuma: chunksize

En basit çözüm pandas'tan çıkmadan dosyayı dilimlerle okumaktır. chunksize verdiğinde read_csv tek bir DataFrame yerine bir yineleyici (iterator) döndürür; her adımda yalnızca o parça bellektedir. Toplam, sayım, en büyük gibi "birleştirilebilir" (associative) hesaplar için idealdir: her parçanın sonucunu bir sözlükte biriktirirsin.

import io
import pandas as pd

csv_metni = """siparis_id,sehir,tutar
1,İzmir,1500
2,Ankara,250
3,İzmir,90
4,Bursa,3200
5,Ankara,610
"""
ciro = {}
parca_sayisi = 0
for parca in pd.read_csv(io.StringIO(csv_metni), chunksize=2):
    parca_sayisi += 1
    for sehir, toplam in parca.groupby("sehir")["tutar"].sum().items():
        ciro[sehir] = ciro.get(sehir, 0) + int(toplam)
print("parça:", parca_sayisi)
print(dict(sorted(ciro.items())))

Gerçek hayatta io.StringIO yerine dosya yolu verirsin ve chunksize=1_000_000 gibi bir değer seçersin. Ayrıca yalnızca gereken sütunları usecols=["sehir", "tutar"] ile, uygun türlerle (dtype={"sehir": "category"}) okumak belleği yarı yarıya düşürebilir.

Parçalı okuma her hesabı kurtarmazOrtalama doğrudan birleştirilemez: parça ortalamalarının ortalaması yanlış sonuç verir. Bunun yerine toplam ve adet biriktirip en sonda böl. Medyan, benzersiz sayısı ya da sıralama gibi tüm veriyi görmesi gereken işlemler için parçalama (chunking) zorlaşır; o noktada polars veya DuckDB'ye geç.

2. polars: hızlı ve tembel DataFrame

polars, Rust ile yazılmış, Apache Arrow bellek düzenini kullanan bir DataFrame kütüphanesidir. pandas'tan üç büyük farkı var: işlemleri tüm çekirdeklere dağıtır, indeks (index) kavramı yoktur ve en önemlisi ifade (expression) tabanlı bir dili vardır. pl.col("tutar") bir sütunu değil, "tutar sütunu üzerinde yapılacak hesabın tarifini" temsil eder; bu tarifleri select, with_columns, filter ve agg içinde kullanırsın.

import polars as pl

siparisler = pl.DataFrame({
    "siparis_id": [1, 2, 3, 4, 5, 6],
    "sehir": ["İzmir", "Ankara", "İzmir", "Bursa", "Ankara", "İzmir"],
    "tutar": [1500, 250, 90, 3200, 610, 720],
})
print(siparisler.shape, siparisler.columns)

buyukler = siparisler.filter(pl.col("tutar") >= 500)
print(buyukler["siparis_id"].to_list())

kdvli = siparisler.with_columns(
    (pl.col("tutar") * 1.2).round(2).alias("kdvli"),
    (pl.col("tutar") >= 1000).alias("buyuk_siparis"),
)
print(kdvli.columns)

ozet = (
    siparisler.group_by("sehir")
    .agg(pl.col("tutar").sum().alias("ciro"), pl.len().alias("adet"))
    .sort("ciro", descending=True)
)
print(ozet.rows())

Okurken şuna dikkat et: hiçbir yerde df["yeni"] = ... gibi yerinde atama yok. polars'ta her işlem yeni bir DataFrame döndürür; zincirleme yazarsın. with_columns sütun ekler ya da değiştirir, select yalnızca istediğin sütunları bırakır, alias sonucun adını verir. group_by çıktısının satır sırası garanti değildir; raporda her zaman sort ekle.

İşpandaspolars
Satır süzmedf[df["tutar"] > 500]df.filter(pl.col("tutar") > 500)
Sütun eklemedf["kdvli"] = df["tutar"] * 1.2df.with_columns((pl.col("tutar") * 1.2).alias("kdvli"))
Sütun seçmedf[["sehir", "tutar"]]df.select("sehir", "tutar")
Gruplamadf.groupby("sehir")["tutar"].sum()df.group_by("sehir").agg(pl.col("tutar").sum())
Birleştirmedf.merge(m, on="id", how="left")df.join(m, on="id", how="left")
Grup içi hesapgroupby(...).transform("mean")pl.col("tutar").mean().over("sehir")
Tek değer almadf["tutar"].sum()df.select(pl.col("tutar").sum()).item()

Lazy API: önce plan, sonra çalıştır

polars'ın asıl gücü tembel (lazy) moddur. df.lazy() ya da doğrudan dosyadan pl.scan_csv(...) / pl.scan_parquet(...) bir LazyFrame döndürür. Üzerine yazdığın filter, group_by, select hemen çalışmaz; polars bir sorgu planı (query plan) kurar. collect() dediğinde planı optimize eder: süzmeyi okumanın içine iter (predicate pushdown), kullanılmayan sütunları hiç okumaz (projection pushdown) ve gerekiyorsa veriyi akış hâlinde (streaming) işler. Sonuç: 60 GB'lık dosyanın yalnızca iki sütununu ve ilgili satırlarını okursun.

Tarayıcıdaki polars derlemesi dosyadan tembel okumayı (scan_csv, scan_parquet) ve polars'ın kendi Parquet okuyucusunu desteklemiyor. Bu sayfadaki çalışan örnekler aynı planı pl.read_csv(...).lazy() ile kuruyor; kendi bilgisayarında pl.scan_csv(...) yazman yeterli, gerisi aynı.

import polars as pl

with open("siparisler.csv", "w", encoding="utf-8") as f:
    f.write("gun,sehir,tutar,iade\n")
    f.write("2025-03-01,İzmir,1500,0\n2025-03-01,Ankara,250,1\n")
    f.write("2025-03-02,İzmir,90,0\n2025-03-02,Bursa,3200,0\n")

plan = (
    pl.read_csv("siparisler.csv").lazy()   # bilgisayarında: pl.scan_csv("siparisler.csv")
    .filter(pl.col("iade") == 0)
    .group_by("gun")
    .agg(pl.col("tutar").sum().alias("ciro"))
    .sort("gun")
)
print(type(plan).__name__)          # henüz hiçbir şey okunmadı
print(plan.collect().rows())        # şimdi çalıştı
Kural: dosyadan okuyorsan scan ile başlaread_csv her şeyi okur, scan_csv yalnızca gerekeni. Etkileşimli keşifte eager mod rahattır; üretimdeki rapor betiklerinde lazy zinciri kur, en sonda bir kez collect() çağır. plan.explain() optimize edilmiş planı metin olarak gösterir.

3. DuckDB: DataFrame'e SQL

Ekipte herkes SQL biliyorsa (Kesit SQL'den hatırlarsın) DuckDB en kısa yoldur. Kurulum gerektirmeyen, süreç içinde çalışan (in-process) bir analitik veritabanıdır; SQLite'ın analitik kardeşi gibi düşün. En sevilen özelliği: Python değişkenindeki bir pandas ya da polars DataFrame'ini tablo adıymış gibi sorgulayabilirsin. Sonucu .fetchall() ile demet listesi, .df() ile pandas, .pl() ile polars olarak alırsın.

import duckdb
import polars as pl

siparisler = pl.DataFrame({
    "musteri": ["Ayşe", "Mert", "Ayşe", "Zeynep", "Mert", "Ayşe"],
    "sehir": ["İzmir", "Ankara", "İzmir", "Bursa", "Ankara", "İzmir"],
    "tutar": [1500, 250, 90, 3200, 610, 720],
})
sorgu = """
    SELECT musteri, SUM(tutar) AS toplam, COUNT(*) AS adet
    FROM siparisler
    WHERE sehir <> 'Bursa'
    GROUP BY musteri
    ORDER BY toplam DESC
"""
print(duckdb.sql(sorgu).fetchall())
pdf = duckdb.sql(sorgu).df()          # pandas DataFrame
print(list(pdf.columns), len(pdf))

DuckDB de sütun tabanlıdır, tüm çekirdekleri kullanır ve bellekten büyük veride diske taşarak çalışabilir. Üstelik dosyaları doğrudan sorgular: SELECT * FROM 'satislar/*.parquet' yazman yeterli; yükleme adımı yok.

SQL'de metin tek tırnakla yazılırDuckDB'de "İzmir" bir sütun adı sayılır ve "Referenced column İzmir not found" hatası alırsın. Metin sabitleri için 'İzmir' kullan. Kullanıcıdan gelen değeri f-string ile sorguya gömme; duckdb.execute("... WHERE sehir = ?", [sehir]) ile parametre geçir (SQL enjeksiyonunu önler).

4. Arrow ve Parquet: ortak dil

Apache Arrow, sütun tabanlı bir bellek biçimidir; polars, DuckDB ve pandas 2+ bu biçimi konuşur, böylece araçlar arasında veri kopyalanmadan el değiştirir. Parquet ise Arrow'un diskteki karşılığı gibidir: sütun tabanlı, şemalı (her sütunun türü dosyada yazar) ve sıkıştırılmış bir dosya biçimi. CSV'ye göre genellikle birkaç kat küçük ve okuması çok daha hızlıdır; çünkü yalnızca istenen sütunlar okunur ve her blokta min/max istatistikleri sayesinde ilgisiz bloklar atlanır.

import pyarrow as pa
import pyarrow.parquet as pq
import polars as pl
import duckdb

tablo = pa.table({
    "musteri_id": [101, 102, 103, 104],
    "segment": ["Bireysel", "KOBİ", "Bireysel", "Kurumsal"],
    "bakiye": [12500.0, 98000.5, 430.0, 1250000.0],
})
pq.write_table(tablo, "musteriler.parquet")

sadece_iki = pq.read_table("musteriler.parquet", columns=["musteri_id", "bakiye"])
print(sadece_iki.column_names, sadece_iki.num_rows)
print(pq.read_schema("musteriler.parquet").field("bakiye").type)

df = pl.from_arrow(pq.read_table("musteriler.parquet"))   # bilgisayarında: pl.read_parquet(...)
print(df.filter(pl.col("segment") == "Bireysel")["musteri_id"].to_list())
print(duckdb.sql("SELECT segment, COUNT(*) FROM 'musteriler.parquet' GROUP BY segment ORDER BY segment").fetchall())
ÖzellikCSVParquet
DüzenSatır tabanlı metinSütun tabanlı ikili
Tür bilgisiYok, her okumada tahminŞemada saklı
BoyutBüyükSıkıştırılmış, genelde birkaç kat küçük
Birkaç sütun okumakYine tüm dosya taranırYalnızca o sütunlar
İnsan okuyabilir miEvetHayır, araç gerekir

5. PySpark: veri tek makineye sığmadığında

Veri terabaytlar seviyesine çıktığında ya da sürekli büyüyen bir veri gölünde (data lake) onlarca ekip aynı veriye eriştiğinde tek makine yetmez. Apache Spark işi bir küme (cluster) üzerindeki birçok makineye dağıtır; PySpark bunun Python arayüzüdür. API'si polars'a benzer: tembel DataFrame, ifadeler ve sonuç için bir eylem (action).

from pyspark.sql import SparkSession
from pyspark.sql import functions as F

spark = SparkSession.builder.appName("anadolu-market").getOrCreate()
df = spark.read.parquet("s3://anadolu-market/siparisler/")   # yüzlerce dosya
ozet = (
    df.filter(F.col("iade") == 0)
      .groupBy("sehir")
      .agg(F.sum("tutar").alias("ciro"), F.count("*").alias("adet"))
      .orderBy(F.desc("ciro"))
)
ozet.show(10)          # eylem: plan burada kümede çalışır
spark.stop()
Kendi bilgisayarında denePySpark tarayıcıda çalışmaz; Java gerektirir. Kurulum: pip install pyspark (ayrıca Java 17). Küçük denemeler için tek makinede local[*] modunda çalışır ama 10 GB altı veride polars veya DuckDB'den genellikle daha yavaştır: küme koordinasyonunun sabit bir maliyeti vardır. Spark'ı "veri gerçekten dağıtık" olduğunda seç.

Hangi boyutta hangi araç?

Veri boyutu (kabaca)Önerilen araçNeden
RAM'in beşte birinden az (ör. 16 GB'ta ~3 GB)pandasEkosistem en geniş, ekip biliyor, yeterince hızlı
RAM'e yakın ya da birkaç katı, tek makinepolars (lazy/streaming) veya DuckDBÇok çekirdek, sütun okuma, bellekten taşınca diske döker
Yüzlerce GB - TB, çok dosya, paylaşılan veri gölüPySpark (ya da bulut veri ambarı)Dağıtık işlem, hata toleransı
Ekip SQL konuşuyor, hızlı ad-hoc analizDuckDBSıfır kurulum, dosyayı doğrudan sorgular

Bu sınırlar kesin değildir; verinin bellekte kapladığı alan sütun türlerine göre CSV boyutundan büyük ya da küçük olabilir. Pratik yol: önce küçük bir örnekle ölç (performans dersindeki gibi), sonra karar ver.

Gerçek işteBir e-ticaret şirketinde günlük sipariş dökümleri genellikle bulut depolamada tarihe göre bölümlenmiş (partitioned) Parquet dosyaları olarak durur: siparisler/yil=2025/ay=03/gun=01/part-0.parquet. Analist sabah raporunu DuckDB ile doğrudan bu klasörlerden sorgular, veri mühendisi gece çalışan dönüşümleri polars ile yazar, aylık tüm geçmişi tarayan modeller ise Spark kümesinde koşar. Aynı Parquet dosyalarını üç araç da okuyabildiği için kimse veriyi kopyalamaz.
Derinleş

Tek bir büyük Parquet dosyası bir noktadan sonra yetmez: her sorgu bütün dosyayı tarar, yeni gün eklemek için dosyayı baştan yazman gerekir. Veri gölleri (data lake) bu yüzden veriyi bölümlere (partition) ayrılmış klasörlerde tutar. Bu bölümde Pedal İzmir'in 116 bin yolculuğunu ay ay bölümlenmiş bir Parquet gölüne yazacak, DuckDB ile sorgulayacak, bölüm budamayı (partition pruning) ölçecek ve küçük dosya sorununu tanıyacaksın. pyarrow, DuckDB ve polars hem yerelde hem tarayıcıda çalışır.

Hive bölümleme: klasör adı = sütun değeri

Hive düzeninde (Hive-style partitioning) bölüm sütunu klasör adına sutun=deger biçiminde yazılır ve dosyanın içinden çıkarılır:

gol/
├── ay=2024-06/data_0.parquet
├── ay=2024-07/data_0.parquet
└── ay=2024-08/data_0.parquet

Okuyucu (DuckDB, polars, Spark, Athena, BigQuery) klasör adlarından ay sütununu geri kurar. Asıl kazanç şudur: WHERE ay = '2024-07' filtresi geldiğinde motor diğer klasörlere hiç girmez. Buna bölüm budama denir ve dosya içindeki satır grubu istatistiklerine dayalı atlamadan (predicate pushdown) da önce gelir.

import veri, duckdb, pathlib, shutil
yol = veri.yol("pedal_izmir", "bk_trips")
shutil.rmtree("gol", ignore_errors=True)
duckdb.sql(f"""
    COPY (SELECT *, strftime(started_at::TIMESTAMP, '%Y-%m') AS ay FROM read_csv_auto('{yol}'))
    TO 'gol' (FORMAT parquet, PARTITION_BY (ay))
""")
print(sorted(str(p) for p in pathlib.Path("gol").rglob("*.parquet")))
print(duckdb.sql("""
    SELECT ay, COUNT(*) AS yolculuk, ROUND(SUM(fare), 2) AS ciro
    FROM read_parquet('gol/**/*.parquet', hive_partitioning = true)
    GROUP BY ay ORDER BY ay
""").fetchall())

Aynı gölü başka araçlarla da yazabilirsin; üçü de aynı klasör yapısını üretir ve birbirinin yazdığını okur:

import pyarrow.parquet as pq
pq.write_to_dataset(tablo, root_path="gol", partition_cols=["ay"])      # pyarrow

df.write_parquet("gol", partition_by="ay")                              # polars (yeni sürümler)
pl.scan_parquet("gol/**/*.parquet", hive_partitioning=True).filter(pl.col("ay") == "2024-07")

Budamayı ölçmek

Budamanın gerçekten çalıştığını tahmin etme, ölç. DuckDB'de EXPLAIN ANALYZE taranan dosya sayısını gösterir (çıktının biçimi sürüme göre değişir):

EXPLAIN ANALYZE
SELECT COUNT(*) FROM read_parquet('gol/**/*.parquet', hive_partitioning = true)
WHERE ay = '2024-07';
│ File Filters: ay = '2024-07'    │
│ Scanning Files: 1/3             │
│ Total Files Read: 1             │

Budamayı bozan üç klasik hata var. Bir: filtreyi bölüm sütununa değil türetilmiş bir ifadeye yazmak (WHERE strftime(started_at, '%Y-%m') = '2024-07' tüm dosyaları açar; WHERE ay = '2024-07' açmaz). İki: tür uyuşmazlığı; yil=2024 klasörü sayıya, ay=07 metne çevrilebilir, karşılaştırmayı aynı türle yap. Üç: filtreyi bir alt sorgunun sonucuna bağlamak; değer sorgu planlanırken bilinmediği için budama yapılamayabilir.

Bölüm sütunu nasıl seçilir? Küçük dosya sorunu

SeçimSonuç
Sorgularda hep filtrelenen, düşük kardinaliteli sütun (gün, ay, ülke)İyi: az klasör, büyük dosyalar, güçlü budama
Yüksek kardinaliteli sütun (user_id, trip_id)Kötü: binlerce klasör, her birinde birkaç KB'lık dosya
Çok seviye (gun/istasyon/saat)Kötü: bölüm sayısı çarpılarak büyür

Küçük dosya sorunu (small files problem) şudur: her dosyanın açılması, üst verisinin (footer) okunması ve bulut depolamada ayrı bir istek gerektirmesi sabit bir maliyettir. 6.000 kullanıcıya bölünmüş bir göl, 3 aylık bir gölden yüzlerce kat yavaş sorgulanabilir. Kaba kural: dosya başına 100 MB ile 1 GB arası hedefle; günlük artımlı yazımlar küçük dosyalar üretiyorsa düzenli olarak sıkıştır (compaction): bir bölümdeki küçük dosyaları okuyup birkaç büyük dosya olarak yeniden yaz. Delta Lake ve Apache Iceberg gibi tablo biçimleri bunu (OPTIMIZE) ve atomik yazmayı hazır sunar.

Bölümleme her derde deva değil116 bin satırlık bir tabloyu bölümlemek ölçülebilir bir kazanç getirmez; tek Parquet dosyası zaten saniyenin altında okunur. Bölümleme gigabaytlar ve sürekli eklenen veri için düşünülür.
Gerçek işteVeri mühendisliği mülakatlarında "S3'teki olay verisini nasıl düzenlersin?" sorusu çok sık gelir. Beklenen cevap: Parquet, tarihe göre Hive bölümleme, dosya boyutu hedefi, sıkıştırma işi ve sorgu motorunun budamayı kullandığını EXPLAIN ile doğrulamak.
Uzmanlaş

Grup içi hesap: over

polars'ta pl.col("tutar").mean().over("sehir") her satıra kendi şehrinin ortalamasını yazar; SQL'deki pencere fonksiyonunun (window function) karşılığıdır. "Şehir ortalamasının iki katından büyük siparişleri işaretle" gibi anomali kuralları tek satıra iner. DuckDB'de aynı iş AVG(tutar) OVER (PARTITION BY sehir) ile yapılır.

import polars as pl

df = pl.DataFrame({
    "siparis_id": [1, 2, 3, 4, 5, 6],
    "sehir": ["İzmir", "İzmir", "İzmir", "Ankara", "Ankara", "Bursa"],
    "tutar": [100, 120, 900, 300, 320, 50],
})
isaretli = df.with_columns(
    pl.col("tutar").mean().over("sehir").round(1).alias("sehir_ort")
).with_columns(
    (pl.col("tutar") > 2 * pl.col("sehir_ort")).alias("supheli")
)
print(isaretli.filter(pl.col("supheli"))["siparis_id"].to_list())
print(isaretli["sehir_ort"].to_list())

Araçlar arasında sıfır kopya

pl.from_pandas(pdf), df.to_pandas(), df.to_arrow() ve duckdb.sql(...).pl() Arrow sayesinde çoğu zaman veriyi kopyalamadan dönüştürür. Bu yüzden "tek araç seç" zorunluluğu yok: ağır birleştirmeyi DuckDB'de, sütun mühendisliğini polars'ta yapıp sonucu görselleştirme için pandas'a verebilirsin.

Bellek ölçümü

Karar vermeden önce ölç: pandas'ta df.memory_usage(deep=True).sum(), polars'ta df.estimated_size("mb"). Metin sütunlarını kategoriye çevirmek ve 64 bit sayıları 32 bite indirmek (pl.Int32, pl.Float32) belleği ciddi azaltır; ancak para tutarlarında hassasiyet kaybını göze almadan float32 kullanma.

Alıştırmalara geç (27)