Python DE 3 — Polars, Arrow & DuckDB

13 thg 7, 2026 3 lượt xem
#data-engineering
#performance
#python
#polars
#duckdb
#arrow

Mở đầu: khi pandas hết hơi

Trong bài pandas chuyên sâu, chúng ta đã thấy pandas là công cụ mặc định để phân tích dữ liệu trong Python suốt hơn một thập kỷ. Nhưng pandas ra đời năm 2008, thiết kế cho những tập dữ liệu vừa với RAM, chạy một luồng, và mọi phép tính đều thực thi ngay (eager). Khi một nhân viên phân tích ngân hàng nhận file Parquet 30 GB chứa toàn bộ giao dịch một quý, pd.read_parquet(...) sẽ nạp toàn bộ vào RAM và... máy treo.

Ba công cụ trong bài này ra đời để giải quyết đúng điểm nghẽn đó:

  • Apache Arrow — định dạng cột chuẩn trong bộ nhớ, cho phép các công cụ chia sẻ dữ liệu không tốn công sao chép (zero-copy).
  • Polars — thư viện DataFrame viết bằng Rust, đa luồng và có lazy evaluation với query optimizer.
  • DuckDB — cơ sở dữ liệu OLAP in-process, chạy SQL trực tiếp trên file Parquet hoặc DataFrame mà không cần nạp trước.

Điểm chung của cả ba: chúng nói chung một "ngôn ngữ" là Arrow, nên ghép lại thành một pipeline liền mạch. Bài này giải thích từng cái, khi nào chọn cái nào, và cách kết hợp chúng.

Quy ước sandbox: môi trường minh hoạ của bộ tài liệu này chạy PostgreSQL; Python, Polars và DuckDB không thực thi tại đây. Mọi khối python/sql bên dưới là minh hoạ — dùng để đọc hiểu, không phải để chạy trực tiếp trong sandbox.


Apache Arrow: "ngôn ngữ chung" của dữ liệu

Vấn đề Arrow giải quyết

Trước Arrow, mỗi công cụ lưu dữ liệu trong bộ nhớ theo cách riêng: pandas dùng block của NumPy, Spark dùng cấu trúc JVM. Muốn chuyển dữ liệu giữa chúng phải serialize (đóng gói thành byte) rồi deserialize ở đầu kia — với bảng vài GB, riêng bước này ngốn cả giây và nhân đôi bộ nhớ.

Apache Arrow (ra mắt 2016) định nghĩa một bố cục bộ nhớ dạng cột, độc lập ngôn ngữ. Mỗi cột là một vùng nhớ liền khối (contiguous buffer) chứa giá trị, kèm "validity bitmap" đánh dấu ô null. Không có overhead theo từng dòng, và bố cục được căn chỉnh để CPU dùng lệnh SIMD.

Zero-copy nghĩa là gì?

Khi hai công cụ cùng dùng Arrow, dữ liệu trong bộ nhớ có bố cục y hệt nhau. Chuyển dữ liệu giữa chúng chỉ là trao một con trỏ (pointer) tới vùng nhớ — không sao chép, không chuyển đổi byte. Đây chính là zero-copy. Polars đọc từ Arrow, DuckDB truy vấn Arrow, pandas chuyển đổi qua lại với Arrow — tất cả gần như miễn phí.

Arrow khác Parquet thế nào?

Đây là điểm hay nhầm. Cả hai đều là định dạng cột, nhưng:

Apache ArrowApache Parquet
Ở đâuTrong bộ nhớ (RAM)Trên đĩa (file)
Tối ưu choTốc độ truy cập, tính toán, chia sẻ zero-copyNén, tiết kiệm dung lượng lưu trữ
NénHầu như không nénNén mạnh (Snappy, Zstd...)
Dùng khiXử lý, trao đổi dữ liệu giữa công cụLưu trữ, đọc/ghi lâu dài

Chúng bổ trợ nhau: dữ liệu nằm dạng Parquet trên đĩa, khi đọc lên thì giải nén thành Arrow trong RAM để xử lý. Vì Parquet cũng là định dạng cột nên bước Parquet → Arrow rất nhanh. Chi tiết về Parquet, ORC, CSV xem bài File & I/O.

Vì sao quan trọng

Arrow đã trở thành de facto standard cho dữ liệu phân tích trong bộ nhớ. Nó được dùng bởi các engine truy vấn (DuckDB, DataFusion, Velox), thư viện khoa học dữ liệu (pandas, Polars, R), và tầng vận chuyển dữ liệu (Arrow Flight SQL, ADBC). Nói cách khác, Arrow là lingua franca — nhờ nó mà một pipeline có thể đi từ Parquet → Polars → DuckDB → Spark mà không phải trả giá cho mỗi lần chuyển đổi.


Polars: DataFrame thế hệ mới

Nó là gì

Polars là thư viện DataFrame viết bằng Rust, phơi ra API Python. Ba đặc điểm cốt lõi phân biệt với pandas:

  1. Đa luồng mặc định — pandas về bản chất chạy một luồng; muốn song song phải thêm Dask. Polars tự động dùng mọi lõi CPU.
  2. Vectorization + SIMD — nhờ Rust và bố cục Arrow, các phép tính chạy trên khối lớn với lệnh SIMD.
  3. Có cả eager và lazy — pandas chỉ có eager (thực thi ngay); Polars có thêm lazy với query optimizer.

Kết quả: trên nhiều tác vụ, Polars nhanh hơn pandas hàng chục lần.

Khác pandas ở đâu

Không có index. pandas gán nhãn cho từng dòng qua index (.loc, .iloc). Polars không có index — mỗi dòng chỉ định danh bằng vị trí số nguyên. Điều này làm API đơn giản và dễ đoán hơn (không còn những lỗi khó hiểu do index không khớp khi merge).

Expression API. Đây là điểm mạnh nhất của Polars. Thay vì viết lambda hay thao tác cột kiểu pandas, bạn mô tả phép tính bằng biểu thức (pl.col(...)), và Polars có thể chạy song song các biểu thức độc lập. Học được cách nghĩ bằng expression, kiến thức Polars của bạn "nội suy" ra hầu hết mọi thao tác.

# (minh hoạ) — so sánh phong cách
import polars as pl

df = pl.read_parquet("giao_dich.parquet")

# Chọn + biến đổi bằng expression; các biểu thức chạy song song
ket_qua = df.select(
    pl.col("ma_kh"),
    pl.col("so_tien"),
    (pl.col("so_tien") * 0.001).alias("phi"),          # biểu thức 1
    pl.col("so_tien").rank(descending=True).alias("hang"),  # biểu thức 2
)

# Lọc bằng expression, không dùng index
lon = df.filter(pl.col("so_tien") > 500_000_000)

# groupby -> agg cũng bằng expression
theo_cn = df.group_by("chi_nhanh").agg(
    pl.col("so_tien").sum().alias("tong"),
    pl.col("so_tien").mean().alias("trung_binh"),
    pl.len().alias("so_giao_dich"),
)

Eager vs Lazy: điểm ăn tiền

Eager (pl.read_parquet, df.filter(...)): mỗi thao tác thực thi ngay, giống pandas. Tiện cho khám phá dữ liệu nhỏ.

Lazy (pl.scan_parquet, kết thúc bằng .collect()): Polars không chạy gì cả. Thay vào đó nó dựng một query plan (đồ thị DAG các phép tính). Chỉ khi bạn gọi .collect(), query optimizer mới phân tích toàn bộ kế hoạch và tối ưu trước khi chạy. Hai tối ưu quan trọng nhất:

  • Projection pushdown — chỉ đọc những cột thực sự cần. File có 50 cột, query dùng 3 cột thì chỉ đọc 3 cột.
  • Predicate pushdown — đẩy điều kiện lọc xuống tận lúc đọc file. Nhờ metadata thống kê trong Parquet, Polars bỏ qua cả khối (row group) không thoả điều kiện, không cần đọc lên rồi mới lọc.

Đây giống hệt cách một database query planner hoạt động — nhưng chạy trên file cục bộ của bạn.

scan_* và streaming: đọc dữ liệu lớn hơn RAM

scan_parquet / scan_csv tạo một LazyFrame mà không nạp dữ liệu. Kết hợp với streaming engine, Polars xử lý dữ liệu theo từng batch nhỏ thay vì nạp hết một lúc — nhờ vậy chạy được tập dữ liệu lớn hơn RAM trên một laptop bình thường. Với pipeline dạng scan → filter/select → group/join → select, gọi .collect(engine="streaming") giảm mạnh đỉnh bộ nhớ.

Muốn xử lý đĩa-sang-đĩa (kết quả cũng lớn hơn RAM), dùng sink_parquet: dữ liệu chảy qua pipeline theo batch và ghi thẳng ra file Parquet, không bao giờ giữ toàn bộ trong RAM.

# (minh hoạ) — lazy scan + filter + groupby, đọc file lớn hơn RAM
import polars as pl

ket_qua = (
    pl.scan_parquet("s3_or_local/giao_dich_*.parquet")   # LazyFrame, chưa đọc gì
    .filter(pl.col("ngay") >= pl.date(2026, 1, 1))       # predicate pushdown
    .filter(pl.col("loai") == "chuyen_khoan")
    .group_by("chi_nhanh")
    .agg(
        pl.col("so_tien").sum().alias("tong_ck"),
        pl.len().alias("so_giao_dich"),
    )
    .sort("tong_ck", descending=True)
    .collect(engine="streaming")     # tối ưu + chạy theo batch
)

# Đĩa-sang-đĩa: không giữ toàn bộ kết quả trong RAM
(
    pl.scan_parquet("giao_dich_*.parquet")
    .filter(pl.col("so_tien") > 0)
    .sink_parquet("giao_dich_sach.parquet")
)

Khi nào Polars thắng pandas

  • Dữ liệu lớn (nhiều triệu tới hàng tỉ dòng), nhất là khi gần hoặc vượt RAM.
  • Cần tốc độ trên máy nhiều lõi (Polars tự dùng hết CPU).
  • Pipeline ETL với nhiều bước lọc/groupby/join (lazy optimizer phát huy).
  • Muốn code sạch, không vướng rắc rối index.

pandas vẫn hợp hơn khi: dữ liệu nhỏ, cần hệ sinh thái khổng lồ (scikit-learn, matplotlib, vô số thư viện nhận pd.DataFrame), hoặc đội đã quen. Xem chi tiết pandas ở bài pandas chuyên sâu.


DuckDB: "SQLite cho analytics"

Nó là gì

DuckDB là một cơ sở dữ liệu OLAP (phân tích) in-process — nghĩa là nó chạy bên trong tiến trình Python của bạn, không cần server, không cần cài đặt phức tạp, giống như SQLite. Nhưng khác SQLite (thiết kế cho OLTP, giao dịch từng dòng), DuckDB là cột hoá và vector hoá, tối ưu cho câu hỏi phân tích: aggregate, join, group by trên hàng triệu dòng.

Slogan quen thuộc: "SQLite cho analytics". Cài đặt chỉ là pip install duckdb, không dependency ngoài.

Sức mạnh cốt lõi: SQL trực tiếp trên file, không cần nạp

Điểm ăn tiền nhất của DuckDB: bạn viết SQL thẳng trên file Parquet/CSV (kể cả file trên S3), và DuckDB tự projection pushdown (chỉ đọc cột cần), predicate pushdown (bỏ qua row group không khớp), và partition pruning (bỏ qua cả file theo đường dẫn). Thường tiết kiệm I/O gấp ~10 lần so với đọc ngây thơ.

-- (minh hoạ) — SQL chạy thẳng trên file Parquet, không nạp trước
SELECT chi_nhanh,
       COUNT(*)         AS so_giao_dich,
       SUM(so_tien)     AS tong_tien,
       AVG(so_tien)     AS trung_binh
FROM   'giao_dich_*.parquet'          -- đọc nhiều file bằng glob
WHERE  ngay >= DATE '2026-01-01'      -- đẩy xuống lúc đọc, bỏ qua row group
GROUP BY chi_nhanh
ORDER BY tong_tien DESC;

Zero-copy với pandas / Polars / Arrow

Nhờ cơ chế replacement scan, DuckDB có thể truy vấn thẳng một pd.DataFrame hay pl.DataFrame hay Arrow Table như thể nó là một bảng, không sao chép dữ liệu. Bạn viết SQL trên biến Python ngay trong cùng tiến trình:

# (minh hoạ) — SQL trên DataFrame Python, không tốn công nạp
import duckdb
import polars as pl

pl_df = pl.read_parquet("giao_dich.parquet")   # DataFrame Polars

# DuckDB "nhìn thấy" biến pl_df như một bảng SQL (zero-copy qua Arrow)
ket_qua = duckdb.sql("""
    SELECT chi_nhanh, SUM(so_tien) AS tong
    FROM pl_df
    WHERE loai = 'chuyen_khoan'
    GROUP BY chi_nhanh
    ORDER BY tong DESC
""")

# Xuất kết quả ra định dạng tuỳ ý — tất cả qua Arrow
df_polars = ket_qua.pl()       # -> Polars DataFrame
df_pandas = ket_qua.df()       # -> pandas DataFrame
tbl_arrow = ket_qua.arrow()    # -> Arrow Table

Khi nào dùng DuckDB

  • Phân tích cục bộ nhanh trên file, không muốn dựng cả một data warehouse.
  • Cần SQL (nhiều người quen SQL hơn API DataFrame) để làm aggregate/join phức tạp.
  • Thay Spark cho quy mô vừa phải — nếu dữ liệu vài chục GB và vừa một máy khoẻ, DuckDB đơn giản và nhanh hơn dựng cluster Spark. (Spark vẫn cần khi dữ liệu thực sự phân tán, xem Spark RDD & DataFrame.)
  • Kết dính các bước Python: dùng SQL cho phần aggregate, còn lại để Polars/pandas lo.

So sánh: pandas vs Polars vs DuckDB

Tiêu chípandasPolarsDuckDB
Kiểu công cụThư viện DataFrameThư viện DataFrameCSDL OLAP in-process
Ngôn ngữ lõiC/Cython + NumPyRustC++
APIMethod chaining, indexExpression, không indexSQL
Đa luồngKhông (mặc định)Có, mặc định
Lazy / optimizerKhôngCó (scan_* + collect)Có (planner SQL)
SQLKhông (cần thư viện)Một phần (pl.sql)Có — cốt lõi
Lớn hơn RAMKhôngCó (streaming)Có (spill ra đĩa)
Đọc Parquet thẳngNạp hếtScan + pushdownQuery thẳng + pushdown
Hệ sinh tháiRất lớnĐang lớn nhanhSQL + tích hợp Arrow
Hợp nhất khiDữ liệu nhỏ, cần thư viện đi kèmETL nhanh, dữ liệu lớnSQL trên file, phân tích cục bộ

Điểm mấu chốt: cả ba interop qua Arrow, nên đây không phải lựa chọn "một mất một còn". Bạn dùng cả ba trong cùng pipeline, chuyển dữ liệu giữa chúng gần như miễn phí.


Kết hợp thực chiến

Một pipeline điển hình trên một máy, dữ liệu lớn hơn RAM:

# (minh hoạ) — Polars scan + DuckDB SQL + xuất Arrow, tất cả zero-copy
import polars as pl
import duckdb

# 1. Polars lazy scan: lọc thô + chọn cột (pushdown), chưa nạp toàn bộ
lazy = (
    pl.scan_parquet("giao_dich_2026_*.parquet")
    .filter(pl.col("so_tien") > 0)
    .select("ma_kh", "chi_nhanh", "loai", "so_tien", "ngay")
)
df = lazy.collect(engine="streaming")   # materialize phần đã thu gọn

# 2. DuckDB làm phần aggregate phức tạp bằng SQL (nhìn df zero-copy)
bao_cao = duckdb.sql("""
    SELECT chi_nhanh,
           loai,
           COUNT(*)                              AS so_gd,
           SUM(so_tien)                          AS tong,
           SUM(so_tien) FILTER (WHERE so_tien > 5e8) AS tong_lon
    FROM df
    GROUP BY chi_nhanh, loai
    QUALIFY SUM(so_tien) > 1e9
    ORDER BY tong DESC
""")

# 3. Xuất ra Arrow để chuyển tiếp (Spark, BigQuery, ghi Parquet...)
tbl = bao_cao.arrow()      # Arrow Table — trao con trỏ, không sao chép
bao_cao.pl().write_parquet("bao_cao.parquet")

Chia việc theo thế mạnh: Polars lo thu gọn dữ liệu lớn (scan + pushdown + streaming), DuckDB lo SQL phân tích (FILTER, QUALIFY, window function viết gọn hơn expression), rồi xuất Arrow để nối tiếp phần còn lại của hệ thống.


Use case thực tế: file Parquet 40 GB, laptop 16 GB RAM

Bối cảnh. Bộ phận rủi ro cần thống kê giao dịch nghi ngờ (trên 500 triệu VND) theo chi nhánh, từ kho Parquet 40 GB (một quý giao dịch), trên laptop 16 GB RAM.

Cách pandas (thất bại). pd.read_parquet("giao_dich/") cố nạp cả 40 GB vào 16 GB RAM → MemoryError, máy treo. Muốn cứu vãn phải tự chia file, đọc từng phần, cộng dồn thủ công — code dài và dễ sai.

Cách Polars (streaming).

# (minh hoạ)
import polars as pl

nghi_ngo = (
    pl.scan_parquet("giao_dich/*.parquet")     # không nạp gì
    .filter(pl.col("so_tien") > 500_000_000)   # predicate pushdown -> bỏ row group
    .group_by("chi_nhanh")
    .agg(pl.len().alias("so_gd_lon"), pl.col("so_tien").sum().alias("tong"))
    .collect(engine="streaming")               # xử lý theo batch, RAM thấp
)

Polars chỉ đọc các cột so_tien, chi_nhanh, bỏ qua row group không có giao dịch lớn, xử lý theo batch → chạy trong vài giây với RAM thấp.

Cách DuckDB (SQL thẳng trên file).

-- (minh hoạ)
SELECT chi_nhanh, COUNT(*) AS so_gd_lon, SUM(so_tien) AS tong
FROM   'giao_dich/*.parquet'
WHERE  so_tien > 500000000
GROUP  BY chi_nhanh
ORDER  BY tong DESC;

DuckDB tự pushdown + spill ra đĩa khi cần, cho ra cùng kết quả mà không cần lo bộ nhớ. Chọn Polars hay DuckDB tuỳ bạn thích API DataFrame hay SQL — cả hai đều thắng pandas ở đây.

Về việc tối ưu bộ nhớ và song song hoá sâu hơn, xem bài Concurrency & Hiệu năng.


Xu hướng: Arrow là "lingua franca" của data stack hiện đại

Điều làm ba công cụ này mạnh không chỉ là tốc độ riêng lẻ, mà là chúng đứng chung trên Arrow. Data stack hiện đại được xây quanh nguyên tắc: giữ bytes tại chỗ, chỉ trao con trỏ, thay vì liên tục serialize/deserialize.

  • Parquet cho đĩa, Arrow cho bộ nhớ và đường truyền.
  • Polars, DuckDB, pandas chia sẻ Arrow trong cùng tiến trình → zero-copy.
  • Arrow Flight SQL / ADBC mang Arrow qua mạng → nối với Spark (Spark RDD & DataFrame) và các engine phân tán.
  • BigQuery (BigQuery tổng quan) và nhiều data warehouse cũng đọc/ghi Parquet và hỗ trợ Arrow, khép kín vòng: dữ liệu di chuyển giữa cloud warehouse, file lake và công cụ cục bộ mà không mất mát ở mỗi ranh giới.

Nắm được Arrow, bạn hiểu vì sao các công cụ tưởng như rời rạc lại ghép được thành một dây chuyền mượt mà.


Ghi nhớ

  • Arrow là định dạng cột trong bộ nhớ, chuẩn chung giúp các công cụ chia sẻ dữ liệu zero-copy. Khác với Parquet — định dạng cột trên đĩa, nén mạnh. Hai cái bổ trợ nhau.
  • Polars viết bằng Rust, đa luồng mặc định, không có index, dùng expression API. Có cả eager và lazy; lazy có query optimizer (projection + predicate pushdown).
  • scan_* + .collect(engine="streaming") (và sink_parquet) cho phép xử lý dữ liệu lớn hơn RAM trên một máy.
  • DuckDB là OLAP in-process ("SQLite cho analytics"), chạy SQL thẳng trên Parquet/Arrow/DataFrame không cần nạp trước, với pushdown mạnh.
  • Cả ba interop qua Arrow — hãy ghép chúng theo thế mạnh: Polars thu gọn dữ liệu lớn, DuckDB làm SQL phân tích, Arrow là "khớp nối" chung.
  • Chọn công cụ theo bài toán: pandas cho dữ liệu nhỏ + hệ sinh thái; Polars cho ETL nhanh, dữ liệu lớn; DuckDB cho SQL trên file và phân tích cục bộ; Spark khi thực sự phải phân tán.

Bài tiếp theo trong series đi sâu vào File & I/O — CSV, Parquet, ORC, Avro. Xem thêm pandas chuyên sâuConcurrency & Hiệu năng.

Bài viết liên quan

Vì sao Python là ngôn ngữ số một của data engineer: vai trò trong pipeline (ingest/transform/orchestrate), hệ sinh thái thư viện (pandas/polars/pyarrow/sqlalchemy), quản lý môi trường (venv/uv/poetry), và khi nào dùng Python vs SQL/Spark.

13 thg 7, 2026 5

Định nghĩa hàm, tham số, *args/**kwargs, lambda, module/package, pip và virtualenv.

13 thg 7, 2026 4

Lớp, kế thừa, đa hình, dunder methods, dataclass, type hints và nguyên tắc viết code sạch.

13 thg 7, 2026 4

Exception handling, context manager (with), đọc/ghi file, JSON/CSV và logging đúng cách.

13 thg 7, 2026 4