一覧DB-09-4 · 保存・複製・復旧

Parquetのパーティション分割

DuckDBで四件の注文を月別Parquetファイルへ書き出します。月の境界の配置、月条件で読むファイル数、ID条件だけの場合を実際の計画で比較します。日付変更後に別の出力先へ作り直し、移動した行と以前のファイルの残り方まで確認します。

説明

パーティションは、データをキーの値や範囲に応じた領域へ分ける方法です。検索条件から不要な領域を除外できれば、読む対象を減らせます。一方、境界をまたぐ値の変更では、保存先の移動と古い領域の扱いが必要になります。

DuckDBで四件の注文を月別Parquetファイルへ書き出します。月の境界の配置、月条件で読むファイル数、ID条件だけの場合を実際の計画で比較します。日付変更後に別の出力先へ作り直し、移動した行と以前のファイルの残り方まで確認します。

1月の境界にある四注文

10月末・11月初日・11月末・12月初日の注文を用意します。

import duckdb,tempfile from pathlib import Path from partition_tools import parquet_plan tmp = tempfile.TemporaryDirectory() root = Path(tmp.name) db = duckdb.connect(':memory:',config={'threads':1}) db.execute("CREATE TABLE orders(id INTEGER,day DATE日付型として保存,amount INTEGER); INSERT INTO orders VALUES(1,'2026-10-31',100),(2,'2026-11-01',200),(3,'2026-11-30',300),(4,'2026-12-01',400)") print(db.execute("SELECT id,CAST(day AS VARCHAR),strftime(day,'%Y-%m')年と月の分割キー AS month,amount FROM orders ORDER BY id").fetchall())

ID 1は10月、2・3は11月、4は12月です。DATEを固定幅の年月へ変換して分割キーにします。月名だけでは年をまたいで混ざるため、2026-11のように年も含めています。

2月別ディレクトリへ書き出す

COPYのPARTITION_BYを使います。

dataset = root/'dataset_v1' db.execute("COPY (SELECT *,strftime(day,'%Y-%m') AS month FROM orders) TO ?保存先を値の引数で渡す (FORMAT parquet,PARTITION_BY(month)Parquetを月別に配置)",[str(dataset)]) print([str(p.relative_to(dataset)分割先の相対パス) for p in sorted(dataset.rglob('*.parquet'))])

month=2026-10、2026-11、2026-12の三領域です。各ディレクトリにParquetファイルがあります。threadsを1にしているので、この例では領域ごとに一ファイルになっています。

3分割キーはディレクトリ名から取得する

全領域をHive形式として読みます。

files = str(dataset/'*/*.parquet') print(db.execute('SELECT id,month,amount FROM read_parquet(?,hive_partitioning=trueパスから分割キーを取得) ORDER BY idファイル配置に依存せず結果を整列',[files]).fetchall())

month列が各行へ付きます。Hive形式ではkey=valueというディレクトリ名から分割キーを復元できます。複数ファイルを一つの表のように読む一方、どの領域の行かも条件に使えます。

411月だけの読取り計画

月条件を指定して実行時の読取りファイル数を取得します。

month_query = "SELECT id,amount FROM read_parquet(?,hive_partitioning=true) WHERE month='2026-11'分割キーで対象を限定" parquet_plan(db,month_query,files)実行計画のファイル数を表示 print(db.execute(month_query+' ORDER BY id',[files]).fetchall())

File Filtersにmonthの条件、Scanning Filesに1/3、Total Files Readに1が出ます。ID 2と3の二行だけです。ディレクトリのキーで不要な二領域を除外することを、パーティションの枝刈りと呼びます。

5ID条件だけでは月を直接指定していない

同じデータをid=2で検索します。

id_query = 'SELECT id,amount FROM read_parquet(?,hive_partitioning=true) WHERE id=2分割キー以外の条件' parquet_plan(db,id_query,files)行の条件とファイル条件を比較 print(db.execute(id_query,[files]).fetchall())

行のFiltersはid=2ですが、monthによるFile Filtersはありません。Parquet内部の統計で読む行グループを減らす最適化と、ディレクトリ単位の枝刈りは別です。出力が一行でも、一ファイルしか調べなかったとは限りません。

6二か月を検索すると二つのパーティションを読み取る

11月以上・12月以下を条件にします。

range_query = "SELECT id,amount FROM read_parquet(?,hive_partitioning=true) WHERE month>='2026-11' AND month<='2026-12'二か月の範囲" parquet_plan読む領域の数(db,range_query,files) print(db.execute(range_query+' ORDER BY id',[files]).fetchall())

対象は2/3ファイル、ID 2・3・4です。固定幅YYYY-MMの文字列なので、この範囲条件が年月の順と一致します。検索で頻繁に使う条件と分割キーの関係を確認します。

7注文日を翌月に変更する

ID 3を11月30日から12月1日へ変更し、別の出力先へ書き出します。

db.execute("UPDATE orders SET day=DATE '2026-12-01'分割先が変わる日付更新 WHERE id=3") next_dataset = root/'dataset_v2'新しい出力先 db.execute("COPY (SELECT *,strftime(day,'%Y-%m') AS month FROM orders) TO ? (FORMAT parquet,PARTITION_BY(month))",[str(next_dataset)]) next_files = str(next_dataset/'*/*.parquet') print(db.execute('SELECT month,COUNT(*) FROM read_parquet(?,hive_partitioning=true) GROUP BY month移動後の領域別件数 ORDER BY month',[next_files]).fetchall())

新しい出力では10月1件・11月1件・12月2件です。分割キーが変わる更新は、同じファイルの値を書き換えるだけでなく、別領域へ行を配置する変更になります。ここでは新しい出力を作って状態を明確にしています。

8古い出力には移動前の行が残る

旧版と新版の11月を読み比べます。

print('old:',db.execute(month_query+' ORDER BY id',[files]移動前の出力).fetchall()) print('new:',db.execute(month_query+' ORDER BY id',[next_files]移動後の出力).fetchall())

旧版は2・3、新版は2だけです。新しいファイルを出力しても、以前のファイルの内容が自動で変更されるわけではありません。公開するデータセットの版を切り替えるなど、読取り側がどちらを参照するかも設計します。

9各ファイルの先頭と末尾を確認する

新しい11月のParquetファイルの識別子を読みます。

sample = next_dataset/'month=2026-11'/'data_0.parquet' raw = sample.read_bytes() footer_size = int.from_bytes(raw[-8:-4],'little')フッター長の4バイトを解析 print('head:',raw[:4]先頭の識別子,'tail:',raw[-4:],'footer_present:',0<footer_size<len(raw)-12) print(db.execute('SELECT SUM(amount),COUNT(*)移動後の全体を照合 FROM read_parquet(?,hive_partitioning=true)',[next_files]).fetchone()) db.close()

先頭と末尾はPAR1で、フッターの長さを末尾8~4バイトのリトルエンディアン整数から取得できます。全体は4件・合計1000円のままで、日付の移動で金額を失っていません。領域ごとの配置と、全体の保存内容の両方を確認します。

まとめ

分割キーの条件により、不要なファイル領域を読取り対象から外せます。

ディレクトリの枝刈りと、Parquet内部の統計による削減は別の処理です。

キーの変更では移動先だけでなく、古い出力と参照先の切替も扱います。

操作履歴と解説を振り返れます。

Python / DuckDB 2.0.0

1
import duckdb,tempfile from pathlib import Path from partition_tools import parquet_plan tmp = tempfile.TemporaryDirectory() root = Path(tmp.name) db = duckdb.connect(':memory:',config={'threads':1}) db.execute("CREATE TABLE orders(id INTEGER,day DATE,amount INTEGER); INSERT INTO orders VALUES(1,'2026-10-31',100),(2,'2026-11-01',200),(3,'2026-11-30',300),(4,'2026-12-01',400)") print(db.execute("SELECT id,CAST(day AS VARCHAR),strftime(day,'%Y-%m') AS month,amount FROM orders ORDER BY id").fetchall())
[(1, '2026-10-31', '2026-10', 100), (2, '2026-11-01', '2026-11', 200), (3, '2026-11-30', '2026-11', 300), (4, '2026-12-01', '2026-12', 400)]
2
dataset = root/'dataset_v1' db.execute("COPY (SELECT *,strftime(day,'%Y-%m') AS month FROM orders) TO ? (FORMAT parquet,PARTITION_BY(month))",[str(dataset)]) print([str(p.relative_to(dataset)) for p in sorted(dataset.rglob('*.parquet'))])
['month=2026-10/data_0.parquet', 'month=2026-11/data_0.parquet', 'month=2026-12/data_0.parquet']
3
files = str(dataset/'*/*.parquet') print(db.execute('SELECT id,month,amount FROM read_parquet(?,hive_partitioning=true) ORDER BY id',[files]).fetchall())
[(1, '2026-10', 100), (2, '2026-11', 200), (3, '2026-11', 300), (4, '2026-12', 400)]
4
month_query = "SELECT id,amount FROM read_parquet(?,hive_partitioning=true) WHERE month='2026-11'" parquet_plan(db,month_query,files) print(db.execute(month_query+' ORDER BY id',[files]).fetchall())
{'File Filters': "(month = '2026-11')", 'Scanning Files': '1/3', 'Total Files Read': '1', 'actual_rows': 2}
[(2, 200), (3, 300)]
5
id_query = 'SELECT id,amount FROM read_parquet(?,hive_partitioning=true) WHERE id=2' parquet_plan(db,id_query,files) print(db.execute(id_query,[files]).fetchall())
{'Filters': '(id = 2)', 'Total Files Read': '3', 'actual_rows': 1}
[(2, 200)]
6
range_query = "SELECT id,amount FROM read_parquet(?,hive_partitioning=true) WHERE month>='2026-11' AND month<='2026-12'" parquet_plan(db,range_query,files) print(db.execute(range_query+' ORDER BY id',[files]).fetchall())
{'File Filters': "(month BETWEEN '2026-11' AND '2026-12')", 'Scanning Files': '2/3', 'Total Files Read': '2', 'actual_rows': 3}
[(2, 200), (3, 300), (4, 400)]
7
db.execute("UPDATE orders SET day=DATE '2026-12-01' WHERE id=3") next_dataset = root/'dataset_v2' db.execute("COPY (SELECT *,strftime(day,'%Y-%m') AS month FROM orders) TO ? (FORMAT parquet,PARTITION_BY(month))",[str(next_dataset)]) next_files = str(next_dataset/'*/*.parquet') print(db.execute('SELECT month,COUNT(*) FROM read_parquet(?,hive_partitioning=true) GROUP BY month ORDER BY month',[next_files]).fetchall())
[('2026-10', 1), ('2026-11', 1), ('2026-12', 2)]
8
print('old:',db.execute(month_query+' ORDER BY id',[files]).fetchall()) print('new:',db.execute(month_query+' ORDER BY id',[next_files]).fetchall())
old: [(2, 200), (3, 300)]
new: [(2, 200)]
9
sample = next_dataset/'month=2026-11'/'data_0.parquet' raw = sample.read_bytes() footer_size = int.from_bytes(raw[-8:-4],'little') print('head:',raw[:4],'tail:',raw[-4:],'footer_present:',0<footer_size<len(raw)-12) print(db.execute('SELECT SUM(amount),COUNT(*) FROM read_parquet(?,hive_partitioning=true)',[next_files]).fetchone()) db.close()
head: b'PAR1' tail: b'PAR1' footer_present: True
(1000, 4)

演習完了