Polars là thư viện DataFrame viết bằng Rust, có API Python, thiết kế quanh một ý tưởng: bạn mô tả cả truy vấn trước, thư viện tối ưu kế hoạch, rồi mới đọc dữ liệu. Với bảng đơn hàng vài triệu dòng, cách làm này cho thấy khác biệt rất rõ so với đọc nguyên file vào bộ nhớ rồi xử lý từng bước như thói quen với pandas. Bài này chạy cùng một báo cáo doanh thu trên 5 triệu dòng đơn hàng mô phỏng bằng pandas 3.0 và Polars 1.42, đo thời gian và RAM đỉnh, đọc kế hoạch truy vấn (explain), rồi nói thẳng khi nào nên và không nên chuyển sang Polars.

Bài liên quan đến hai bài trước trong series về xử lý dữ liệu bảng: Làm sạch dữ liệu với pandas và NumPy và vector hoá. Môi trường: Apple M4, 16 GB RAM, Python 3.12, polars==1.42.1, pandas==3.0.3, pyarrow==25.0.0, numpy==2.5.1; mã nguồn ở code/s7-53. Dữ liệu là dữ liệu mô phỏng tự sinh, không phải dữ liệu khách hàng thật. Máy lúc đó còn chạy các tác vụ khác, nên mọi số đo thời gian là trung vị của 5 lần chạy và chỉ có giá trị so sánh tương đối.

Vấn đề: báo cáo chạy được trên laptop, đến lúc dữ liệu gấp mười thì hết RAM

Một kịch bản quen thuộc ở doanh nghiệp bán lẻ: file xuất đơn hàng hằng ngày được cộng dồn thành CSV vài trăm MB, rồi có người viết notebook pandas để ra báo cáo doanh thu theo thành phố và kênh bán. Notebook chạy tốt với dữ liệu một quý. Khi dữ liệu thành một năm, nó chậm hơn nhiều và RAM bắt đầu là vấn đề. Nguyên nhân thường không phải pandas "tệ", mà là mô hình thực thi: mỗi dòng code chạy ngay (eager), đọc tất cả cột, và tạo thêm bản trung gian.

Polars giải quyết bằng ba thứ có thể kiểm chứng: thực thi lazy có bộ tối ưu truy vấn, bộ máy thực thi song song theo biểu thức và (từ các bản gần đây) chạy theo lô (streaming). Theo tài liệu Polars về kiểu dữ liệu, Polars dùng định dạng cột Arrow cho bộ nhớ, nhưng tài liệu cũng nói hiệu năng chủ yếu đến từ bộ máy truy vấn, tối ưu kế hoạch và thực thi biểu thức song song, chứ không chỉ từ cách bố trí bộ nhớ.

Dữ liệu mô phỏng và truy vấn dùng để so sánh

Script gen_data.py sinh bảng đơn hàng gồm 9 cột: order_id, customer_id, order_date, city, channel, sku, qty, unit_price, status. Với 5 triệu dòng, file CSV nặng 280 MB, file Parquet nén chỉ 49,5 MB. Truy vấn đo là một báo cáo điển hình: lấy đơn đã thanh toán từ 01/04/2026, tính doanh thu, nhóm theo thành phố và kênh, lấy 5 nhóm doanh thu cao nhất.

import polars as pl
from datetime import date

def query(lf):
    return (lf.filter((pl.col("status") == "paid") & (pl.col("order_date") >= date(2026, 4, 1)))
              .with_columns(revenue=pl.col("qty") * pl.col("unit_price"))
              .group_by("city", "channel")
              .agg(orders=pl.len(), revenue=pl.col("revenue").sum(), avg_qty=pl.col("qty").mean())
              .sort("revenue", descending=True).head(5))

out = query(pl.scan_csv("orders.csv", try_parse_dates=True)).collect(engine="in-memory")

Phiên bản pandas làm cùng việc theo kiểu quen thuộc: read_csv, lọc bằng mặt nạ boolean, assign, groupby().agg(). Ở mọi cách chạy, hàng đầu tiên của kết quả giống hệt nhau (HCM, web, 275.043 đơn, doanh thu 196.076.218.000 VND), nên so sánh là công bằng về kết quả.

Lazy API: scan, kế hoạch truy vấn và collect

Khác biệt cốt lõi là read_csv và scan_csv. pl.read_csv đọc ngay và trả về DataFrame. pl.scan_csv chỉ trả về LazyFrame, một "lời hứa tính toán"; chưa dòng dữ liệu nào được đọc cho tới khi bạn gọi .collect(). Theo trang Execution của tài liệu Polars, mỗi dòng code được thêm vào đồ thị truy vấn nội bộ và đồ thị này được tối ưu trước khi chạy.

Ta có thể nhìn kế hoạch trước và sau tối ưu bằng explain. Output thật của demo_explain.py:

=== explain(optimized=False) ===
SLICE[offset: 0, len: 3]
  SORT BY [descending: [true]] [col("revenue")]
    AGGREGATE[maintain_order: false]
      [len().alias("orders"), col("revenue").sum()] BY [col("city"), col("channel")]
      FROM
       WITH_COLUMNS:
       [[(col("qty")) * (col("unit_price"))].alias("revenue")] 
        FILTER [(col("order_date")) >= (2026-04-01)]
        FROM
          FILTER [(col("status")) == ("paid")]
          FROM
            Csv SCAN [orders.csv]
            PROJECT */9 COLUMNS
            ESTIMATED ROWS: 5609054
=== explain() (đã tối ưu) ===
SORT BY [slice: (0, 3, dynamic_pred: cbf69941-1418-49a9-bc0b-32fe3ee57421), descending: [true]] [col("revenue")]
  FILTER col("revenue").dynamic_predicate()
  FROM
    AGGREGATE[maintain_order: false]
      [len().alias("orders"), col("revenue").sum()] BY [col("city"), col("channel")]
      FROM
      simple π 3/3 ["city", "channel", "revenue"]
         WITH_COLUMNS:
         [[(col("qty")) * (col("unit_price"))].alias("revenue")] 
          simple π 4/4 ["city", "channel", "qty", ... 1 other column]
            Csv SCAN [orders.csv]
            PROJECT 6/9 COLUMNS
            SELECTION: [([(col("order_date")) >= (2026-04-01)]) & ([(col("status")) == ("paid")])]
            ESTIMATED ROWS: 5609054

Ba thay đổi hiện rõ trong kế hoạch tối ưu. Thứ nhất, PROJECT 6/9 COLUMNS: chỉ 6 trong 9 cột được đọc từ file (projection pushdown), các cột order_id, customer_id, sku không bao giờ được nạp. Thứ hai, SELECTION ở ngay bước scan: hai bộ lọc được gộp thành một và áp dụng khi đọc (predicate pushdown), nên dòng không thoả điều kiện bị bỏ sớm. Thứ ba, SORT BY [slice: ...] kèm dynamic_predicate: vì chỉ cần top N, Polars gộp head vào bước sắp xếp thay vì sắp xếp toàn bộ rồi cắt. Danh sách đầy đủ các tối ưu hoá (predicate, projection, slice pushdown, loại bỏ truy vấn con trùng lặp, đơn giản hoá biểu thức, sắp thứ tự join, ép kiểu, ước lượng lực lượng) có ở trang Optimizations.

Một lợi ích ít được nhắc: schema được suy ra mà không đọc dữ liệu. lf.collect_schema() trả ngay Schema({'city': String, 'channel': String, 'orders': UInt32, 'revenue': Int64}). Lỗi tên cột sai hoặc kiểu không khớp được phát hiện trước khi tốn thời gian đọc file lớn.

Chọn engine: in-memory và streaming

Theo tài liệu LazyFrame.collect, tham số engine nhận "auto", "in-memory", "streaming" hoặc "gpu". Chế độ "auto" dùng engine đặt qua Config.set_engine_affinity hoặc biến môi trường POLARS_ENGINE_AFFINITY, và với Polars 1.42 (bản dùng trong bài) rơi về "in-memory" nếu không đặt gì; docstring của bản này ghi streaming "sẽ sớm trở thành mặc định".

Tài liệu stable trên trang chủ luôn theo bản Polars mới nhất nên có thể mô tả mặc định khác với bản bạn cài; hãy đối chiếu docstring của bản đang dùng (help(pl.LazyFrame.collect)). Streaming xử lý truy vấn theo từng lô thay vì "một khối" như engine in-memory, nhờ đó giảm áp lực bộ nhớ; nếu engine được chọn không chạy được một truy vấn, Polars tự rơi về in-memory. Chế độ GPU được tài liệu ghi là chưa ổn định (unstable) và cần card Nvidia cùng cudf-polars; máy trong bài không có GPU nên phần đó cần GPU, chưa chạy trong bài.

Trong bài tôi truyền engine tường minh để kết quả không phụ thuộc cấu hình môi trường. Với dữ liệu lớn hơn RAM, tài liệu Execution khuyên dùng engine="streaming", đồng thời nói rõ Polars "có thể" xử lý theo lô chứ không cam kết mọi truy vấn đều chạy được theo kiểu này; vì vậy hãy thử trên truy vấn thật của bạn thay vì giả định.

Kết quả đo: 5 triệu dòng, một máy 16 GB

Mỗi cách chạy là một tiến trình Python riêng (bench.py <chế độ>) để RAM đỉnh (ru_maxrss) không bị lẫn. Bảng dưới là trung vị thời gian của 5 lần chạy và RAM đỉnh; thời gian gồm cả đọc file lẫn tính toán.

Cách chạyNguồnThời gian (s)RAM đỉnh (MB)
pandas, đọc toàn bộCSV 280 MB3,311.397
pandas, đọc toàn bộParquet 49,5 MB0,711.315
pandas, read_parquet(columns=, filters=)Parquet0,54665
Polars read_csv rồi tính, tắt tối ưuCSV0,41880
Polars lazy, engine="in-memory"CSV0,37628
Polars lazy, engine="streaming"CSV0,32550
Polars lazy, engine="in-memory"Parquet0,11281
Polars lazy, engine="streaming"Parquet0,09205

Về độ nhiễu: máy đo là máy dùng chung, load average thường trên 20 với 10 nhân. Ở một lần đo trước, lần chạy đầu của pandas đọc Parquet mất khoảng 7,8 giây, và lúc máy bận hơn nữa có lần pandas mất 38 giây. Tôi đã kiểm tra nghi vấn do chuyển cột order_date từ object sang datetime64: bước to_datetime chỉ mất 0,56 giây nên không phải nguyên nhân; nhiều khả năng là tranh chấp CPU và bộ nhớ, và pandas dùng nhiều RAM hơn nên chịu ảnh hưởng nặng hơn. Bảng dưới đo lại 5 lần liên tiếp lúc load average khoảng 8,7; bench_output.txt giữ từng lần. Hãy xem chúng là thứ tự độ lớn, không phải số tuyệt đối.

Cách đọc kết quả, cẩn thận để không kết luận quá đà:

  • Cùng truy vấn, khác biệt về thời gian là lớn: với CSV, pandas mất 3,31 giây, Polars lazy khoảng 0,37 giây, nhanh cỡ 9 lần. Phần lớn chênh lệch đến từ đọc và phân tích CSV đa luồng và từ nhóm theo song song.
  • Lazy không phải lúc nào cũng nhanh hơn eager về thời gian: dòng "tắt tối ưu" (0,41 giây) và dòng lazy in-memory (0,37 giây) chênh nhau trong biên nhiễu của máy đang chạy tác vụ khác. Lợi ích rõ của lazy nằm ở RAM đỉnh: 628 MB so với 880 MB, vì 3 cột không dùng không được đọc.
  • Định dạng file quan trọng ngang engine: chuyển từ CSV sang Parquet làm RAM đỉnh của Polars giảm từ 628 xuống 281 MB và thời gian giảm khoảng 3 lần, vì Parquet là định dạng cột, đọc được từng cột và có thể bỏ qua cả nhóm dòng dựa vào thống kê.
  • pandas cũng đẩy cột và bộ lọc xuống bước đọc: khi gọi read_parquet với columns và filters (qua PyArrow), pandas giảm từ 1.315 MB xuống 665 MB và thời gian từ 0,71 xuống 0,54 giây. Khoảng cách với Polars thu hẹp, nhưng vẫn còn: 0,54 giây so với 0,11 giây và 665 MB so với 281 MB.
  • Streaming có ích rõ rệt hơn khi dữ liệu lớn: với 5 triệu dòng, streaming chỉ tiết kiệm thêm khoảng 12 đến 27% RAM (628 xuống 550 MB với CSV, 281 xuống 205 MB với Parquet) so với in-memory. Lợi thế thật là chạy được truy vấn mà bộ nhớ không chứa nổi, điều không thể minh hoạ trên máy dùng chung.

Khi giới hạn số luồng bằng biến môi trường POLARS_MAX_THREADS=2 (mô phỏng một container nhỏ), truy vấn Polars lazy trên CSV mất 0,55 giây và dùng 515 MB; trên Parquet mất 0,13 giây và dùng 237 MB, vẫn nhanh hơn pandas ở cấu hình đủ nhân (đây là một lần chạy, không phải trung vị; hai dòng cuối của bench_output.txt). Điều này đáng nhớ khi chạy trong container có giới hạn CPU.

Biểu thức, cửa sổ và join trong một đồ thị lazy

Sức mạnh thật của Polars là biểu thức (pl.col(...)) kết hợp được với nhau và chạy song song. Ví dụ dưới đây làm ba việc trong một đồ thị: ghép bảng vùng miền, xếp hạng SKU theo doanh thu trong từng vùng bằng hàm cửa sổ .over("region"), và gom doanh thu theo tháng bằng group_by_dynamic. Hai kết quả dùng chung nhánh đọc và lọc, nên gọi pl.collect_all để nhánh chung chỉ tính một lần (đúng khuyến nghị trong tài liệu Execution cho các truy vấn rẽ nhánh).

orders = pl.scan_parquet("orders_sink.parquet")
regions = pl.LazyFrame({"city": ["HCM", "HN", "DN", "CT", "HP", "Khac"],
                        "region": ["Nam", "Bắc", "Trung", "Nam", "Bắc", "Khác"]})
paid = (orders.filter(pl.col("status") == "paid")
        .with_columns(revenue=pl.col("qty") * pl.col("unit_price"))
        .join(regions, on="city", how="left"))

top_sku = (paid.group_by("region", "sku").agg(revenue=pl.col("revenue").sum())
           .with_columns(rank=pl.col("revenue").rank("dense", descending=True).over("region"))
           .filter(pl.col("rank") <= 2).sort("region", "rank"))
monthly = (paid.sort("order_date").group_by_dynamic("order_date", every="1mo", group_by="region")
           .agg(orders=pl.len(), revenue=pl.col("revenue").sum())
           .filter(pl.col("region") == "Nam").sort("order_date"))
a, b = pl.collect_all([top_sku, monthly])

File orders_sink.parquet được tạo từ CSV bằng pl.scan_csv(...).sink_parquet(...), thao tác chuyển đổi chạy theo lô và không nạp cả file CSV vào một DataFrame. Output thật của demo_pipeline.py (rút gọn):

sink_parquet: 0.69 s, RAM đỉnh 545 MB
collect_all: 0.37 s
┌────────┬─────────┬───────────┬──────┐
│ region ┆ sku     ┆ revenue   ┆ rank │
╞════════╪═════════╪═══════════╪══════╡
│ Bắc    ┆ SKU1408 ┆ 530026000 ┆ 1    │
│ Bắc    ┆ SKU1582 ┆ 519179000 ┆ 2    │
│ Nam    ┆ SKU1295 ┆ 680433000 ┆ 1    │
│ Nam    ┆ SKU0619 ┆ 677310000 ┆ 2    │
└────────┴─────────┴───────────┴──────┘

RAM đỉnh 545 MB của sink_parquet cho thấy "streaming" không có nghĩa là "gần như không tốn RAM": đó là mức bộ nhớ thực tế khi xử lý 280 MB CSV có cột chuỗi, và nó vẫn thấp hơn nhiều so với nạp toàn bộ vào pandas. Hãy đo trên dữ liệu của bạn.

Khác biệt ngữ nghĩa với pandas 3.0

Chuyển từ pandas sang Polars không chỉ là đổi cú pháp. Một số khác biệt ngữ nghĩa dễ gây lỗi âm thầm, nên demo_semantics.py kiểm chứng từng điểm trên dữ liệu 4 dòng. Output thật:

pandas dtypes : {'city': 'str', 'amount': 'float64'}
polars schema : {'city': String, 'amount': Float64}
pandas  amount.isna().sum() = 2 | mean = 200.0
polars  null_count = 1 | nan_count = 1 | mean = nan
polars  sau fill_nan(None): null_count = 2 | mean = 200.0
pandas CoW: view[0] vẫn = 100.0 (không bị đổi theo)
chained assignment: df.amount[1] = nan (không ghi được vào df)
  • Thiếu dữ liệu: null khác NaN. pandas coi cả hai là "thiếu" (isna() đếm 2, mean() bỏ qua nên ra 200,0). Polars phân biệt: null là giá trị thiếu, còn NaN là một số thực đặc biệt, nên cột có 1 null và 1 NaN cho mean = nan. Tài liệu Polars nhấn mạnh không được nhầm hai khái niệm này. Dữ liệu đến từ pandas thường cần fill_nan(None) trước khi tính trung bình.
  • pandas 3.0 đã đổi nhiều thứ. Theo What's new in 3.0.0 (ngày 21/01/2026), kiểu chuỗi riêng str được bật mặc định (dùng PyArrow nếu đã cài), và Copy-on-Write là hành vi mặc định: kết quả của mọi phép lập chỉ mục luôn "hành xử như một bản sao", nên chained assignment ngừng hoạt động. Output ở trên khớp: pdf["amount"][1] = 5.0 không ghi được vào pdf (pandas còn phát cảnh báo ChainedAssignmentError), và view[0] không đổi khi sửa pdf.
  • Không có chỉ mục (index). Polars không có index hàng; việc nối, nhóm đều theo cột. Code pandas dựa vào .loc[label] hoặc căn chỉnh theo index phải viết lại bằng join hoặc over.
  • Thứ tự nhóm không cố định. group_by của Polars mặc định không giữ thứ tự xuất hiện (maintain_order: false trong kế hoạch ở trên); cần thứ tự thì thêm maintain_order=True hoặc sort rõ ràng.

Về pandas, số đo ở bài này dùng pandas 3.0.3 đã có kiểu str mặc định, nên phép so sánh trong bài không bị thiệt cho pandas vì cột chuỗi dạng object như ở các bản cũ.

Khi nào không nên chuyển sang Polars

  • Dữ liệu nhỏ. Dưới vài chục MB, hai thư viện đều tính trong chớp mắt; chi phí chuyển đổi (viết lại code, đào tạo nhóm) lớn hơn lợi ích.
  • Hệ sinh thái pandas. Nhiều thư viện nhận trực tiếp DataFrame của pandas hoặc mảng NumPy (feature engineering, scikit-learn, nhiều thư viện vẽ biểu đồ). Polars có to_pandas() và to_numpy() để nối sang, nhưng mỗi lần chuyển là một lần sao chép bộ nhớ; hãy chuyển ở rìa pipeline chứ không lặp đi lặp lại.
  • Truy vấn không hợp với lazy. Phân tích khám phá từng ô lệnh trong notebook, nơi bạn muốn xem kết quả trung gian sau mỗi bước, thì eager (read_csv) của Polars hoặc pandas tiện hơn. Lazy phát huy nhất ở pipeline lặp lại.
  • Dữ liệu thật sự lớn hơn một máy. Polars giúp một máy xử lý được nhiều hơn, nhưng cần dữ liệu phân tán hàng trăm GB đến TB thì phải cân nhắc Spark, DuckDB hoặc kho dữ liệu; bài này không đo các hệ đó nên không so sánh.
  • API còn phát triển nhanh. Polars đã ở phiên bản 1.x, nhưng tham số như engine của collect và các cờ tối ưu mới xuất hiện qua các bản; ghim phiên bản trong requirements.txt và đọc ghi chú phát hành khi nâng cấp.

Lỗi thường gặp

  • So sánh ngày với chuỗi. Khi tôi viết bản pandas đọc từ Parquet, cột order_date có kiểu date và phép lọc >= "2026-04-01" báo TypeError: '>=' not supported between instances of 'datetime.date' and 'str'. Phải ép pd.to_datetime trước. Trong Polars, dùng datetime.date(2026, 4, 1) hoặc pl.date(2026, 4, 1).
  • Dùng head() để phát triển rồi tin kết quả. Tài liệu Execution cảnh báo kết quả trên mẫu nhỏ "có thể không đại diện" cho toàn bộ dữ liệu. Kiểm tra kế hoạch bằng explain và chạy trên mẫu ngẫu nhiên có kiểm soát.
  • Gọi collect() nhiều lần trên cùng một LazyFrame. Mỗi lần gọi tính lại từ đầu vì LazyFrame không bảo đảm lưu đệm kết quả trung gian; dùng pl.collect_all cho các truy vấn chia sẻ nhánh chung.
  • Biến lazy thành eager quá sớm. Gọi .to_pandas() hoặc .collect() giữa pipeline làm mất tối ưu pushdown phía trước. Giữ lazy đến bước cuối.
  • Đếm bằng cách cũ. Dùng pl.len() để đếm số dòng trong agg; kết quả có kiểu UInt32 (xem schema ở trên), cần ép sang Int64 nếu so sánh hoặc xuất ra hệ thống đòi kiểu có dấu.

Checklist

  • Lưu dữ liệu phân tích dưới dạng Parquet; dùng scan_parquet và giữ truy vấn ở dạng lazy đến bước cuối.
  • Đọc explain() để xác nhận cột thừa không bị đọc (PROJECT 6/9 COLUMNS) và bộ lọc nằm ở bước scan (SELECTION).
  • Đặt engine tường minh khi cần kết quả tái lập; thử engine="streaming" trên truy vấn thật khi dữ liệu gần chạm RAM.
  • Giới hạn luồng bằng POLARS_MAX_THREADS khi chạy trong container có giới hạn CPU.
  • Phân biệt null và NaN, ghim phiên bản trong requirements.txt, chuyển sang pandas hoặc NumPy ở rìa pipeline.

Cài đặt: python3.12 -m venv .venv && .venv/bin/pip install -r requirements.txt, chạy python gen_data.py 5000000 để sinh dữ liệu, rồi bench.py <chế độ>, demo_explain.py, demo_semantics.py và demo_pipeline.py trong code/s7-53; các file *_output.txt là output đã dán. Phần bench dùng tối đa khoảng 1,6 GB RAM (chủ yếu là pandas đọc CSV); requirements.txt ghim cả gói gián tiếp (python-dateutil, tzdata) ở bản ra trước ngày đăng bài; file dữ liệu và .venv đã được xoá sau khi lấy số liệu.

Bài tiếp theo

Pipeline dữ liệu end-to-end: crawl giá sản phẩm, phân tích và dashboard Streamlit. Bài kế tiếp ghép các mảnh của series: crawl dữ liệu, làm sạch, phân tích và dựng dashboard, nơi một bước xử lý dữ liệu nhanh và tiết kiệm RAM như Polars giúp pipeline chạy đều đặn.