Supabase(PostgREST)で .range() を使ってテーブルを全件回したら、合計の行数は合っているのに、区分ごとの内訳だけがずれることがありました。原因は .order() を付けていなかったことです。
件数の合計が一致するので「取りこぼしは無い」と読んでしまい、気づくのが遅れました。同じ書き方をしている人向けに、症状・原因・直し方をまとめます。
何が起きたか
約7万6千行のテーブルを、1,000行ずつ .range() で取って、ある列の値ごとに数えていました。
// 順序を指定していない(これがまずい)
const PAGE = 1000;
let from = 0;
const rows = [];
while (true) {
const { data, error } = await supabase
.from("companies")
.select("id, status, detail") // detail は jsonb の重い列
.range(from, from + PAGE - 1);
if (error) throw error;
rows.push(...data);
if (data.length < PAGE) break;
from += PAGE;
}
同じテーブルを、区分ごとに count: "exact", head: true で数えた結果と比べると、こうなりました。
| 区分 | count: "exact" |
順序なし .range() で集計 |
|---|---|---|
| A | 10,502 | 9,722 |
| B | 48,965 | 49,094 |
| C | 7,191 | 6,578 |
| 合計 | 76,011 | 76,011 |
合計は同じです。中身を見ると、同じ id が2回出ている行と、一度も出てこない行が両方ありました。重複と欠落が打ち消し合って、件数だけ合っていたわけです。
原因:ORDER BY が無いと、行の順番は毎回同じとは限らない
.range(from, to) は PostgREST 側で LIMIT / OFFSET になります。PostgreSQL は ORDER BY が無い問い合わせの行の順番を保証しません。
ページごとに別の問い合わせとして実行されるので、
- 1ページ目と2ページ目で、行の並びが違っていてもおかしくない
- その結果、ある行は2ページに出て、別の行はどのページにも出ない
ということが起きます。たとえば PostgreSQL の同期スキャン(synchronize_seqscans、既定で on)は、大きいテーブルの全件スキャンを、別のスキャンが進んでいる途中の位置から始めることがあります。私の場合は、id だけを選ぶ軽い問い合わせでは再現せず、jsonb の重い列を含めたときに出ました。
直し方
1. .range() の前に、主キーで .order() を付ける
const { data, error } = await supabase
.from("companies")
.select("id, status, detail")
.order("id", { ascending: true }) // 必ず付ける
.range(from, from + PAGE - 1);
これで count: "exact" の結果と区分ごとに一致しました。
並べる列は**値が重ならない列(主キーなど)**にします。created_at のように同じ値が複数ありうる列だけで並べると、同じ値の行どうしの順番は決まらないので、同じことが起きえます。その場合は .order("created_at").order("id") のように主キーを足します。
2. もっと堅くするなら、OFFSET をやめて「前回の最後の id より後」で取る
let lastId = 0;
while (true) {
const { data, error } = await supabase
.from("companies")
.select("id, status, detail")
.gt("id", lastId)
.order("id", { ascending: true })
.limit(PAGE);
if (error) throw error;
if (data.length === 0) break;
rows.push(...data);
lastId = data[data.length - 1].id;
}
OFFSET は先頭から読み飛ばすので、後ろのページほど遅くなります。途中で行が追加・削除されたときにずれる心配も減ります。
気づくための検算
全件を回して集計するコードを書いたら、合計ではなく内訳を count と突き合わせます。
const { count } = await supabase
.from("companies")
.select("id", { count: "exact", head: true })
.eq("status", "A");
もう一つ、取れた id の重複を数えるのも手早いです。
const ids = rows.map((r) => r.id);
console.log(ids.length, new Set(ids).size); // 2つが違えば重複している
まとめ
-
.range()で回すなら、直前に主キーで.order() - 合計が合っていても、内訳がずれていることがある
- 正は
count: "exact", head: true。集計を作ったら内訳で突き合わせる
重複チェックのような処理(「すでに渡した行を除く」など)でこれを落とすと、同じ行を2回扱うことになるので特に注意が要ります。
このテーブルのような営業リストを、裏を取って作る手順(有料・980円):https://note.com/kaito_cometa/n/n9632db4153bf
生成AI(Claude Code)に書かせて一人で会社を回している記録を note に書いています。
https://note.com/kaito_cometa