A reader can be restricted to a subset of a file's rows — by value (filter=) or by random draw
(sample_fraction=) — and can return them in a chosen order (sort_by=). Everything documented in
Reading parquet files then behaves as if the file only ever contained the surviving
rows, in the requested order. This page is the reference for those three options and how they
compose.
parquet_filterparquet_open_reader(reader, filename, filter=filt) restricts a reader to only the rows matching
a type(parquet_filter). Once a filter is set, it is completely transparent to everything else:
parquet_get_nrows, parquet_read_column, parquet_prefetch_columns, and
parquet_close_reader(..., print_stat=.true.) all behave exactly as if the file only ever
contained the matching rows — there is no separate "filtered count" to track yourself.
type(parquet_filter) :: filt
type(parquet_reader) :: reader
integer(int32) :: nrows
integer(int32), allocatable :: ra(:)
call filt%add("(ra > 180 and ra <= 360) or ra is_null")
call filt%add("id is_not_null")
call parquet_open_reader(reader, "data.parquet", filter=filt)
call parquet_get_nrows(reader, nrows) ! already the filtered row count
allocate(ra(nrows))
call parquet_read_column(reader, "ra", ra) ! already just the matching rows
call parquet_close_reader(reader)
Each filt%add(rule) call contributes one boolean expression over the file's columns. An
expression is either a single clause or several clauses combined with and, or, not and
parentheses:
expr := or_expr
or_expr := and_expr { or and_expr }
and_expr := not_expr { and not_expr }
not_expr := [ not ] not_expr | primary
primary := '(' expr ')' | clause
clause := <column> <op> [ <value> ]
| Precedence | not binds tightest, then and, then or — so a or b and c means a or (b and c), and not a and b means (not a) and b. Parentheses override. |
| Keywords | and / or / not, in any case (AND, And, and). Only whole tokens are keywords, so a column named android or nothing is unaffected. Fortran-style .and. and C-style && are not accepted. |
| Operators | >, >=, <, <=, ==, /=, is_null, is_not_null, is_nan, is_not_nan. A clause's operator must be surrounded by spaces ("v > 3", not "v>3"); parentheses need no surrounding spaces. is_nan/is_not_nan are accepted only for a floating-point column (float32/float64, and a half_float column written by some other tool) — any other column type is rejected, since no value of it could ever be a NaN. |
| Values | A bare number for a numeric column (ra > 180), true/false for a boolean column (flag == true), a double-quoted string for a string column (name == "abell_1"), or a double-quoted ISO-8601 literal for a date/time/timestamp column (see below). is_null/is_not_null/is_nan/is_not_nan take no value. A quoted value may contain spaces, parentheses, and the keywords themselves — it is read as one token. An inf/-inf value is accepted as an ordinary bound (v < inf); a bare nan is rejected, because every comparison against a NaN is false and every /= against it is true, so such a clause could only ever match nothing or everything — say is_nan/is_not_nan instead. |
| Column names | May be a dotted struct-leaf path (main.inner.age > 35). A column name cannot contain spaces. |
Several %add calls |
AND-combined: two calls mean (expr1) and (expr2). This keeps every filter written as one clause per call meaning exactly what it always did; write or inside a single rule when you want alternatives. |
| Limits | 32 levels of nesting, 1024 expression terms per filter, 8192 characters per rule, and, within one clause, 64 characters per column name and 512 per value — each reported as a clean error rather than a crash. The first three are published constants you can check a rule against beforehand; see Read-only limits. |
in, between and wildcard/like matching are deliberately not supported: the first two are
shorthand for what the grammar already expresses (x in (1,2,3) is x == 1 or x == 2 or x == 3;
x between 1 and 9 is x >= 1 and x <= 9), and pattern matching is genuinely different work.
A comparison against a Null is neither true nor false but unknown, and only rows that come out true survive:
| expression | a row whose x is Null |
|---|---|
x > 5 |
excluded (unknown) |
not x > 5 |
still excluded — negating unknown is unknown, not true |
x > 5 or y > 5 |
survives only if y > 5 is true |
x is_null |
survives |
not x is_null |
excluded; identical to x is_not_null |
is_null/is_not_null are the only operators that answer true/false for a Null row, so they are
the only way to select one. This matches SQL's WHERE clause and Arrow's own kernels — in
particular, not does not let Null rows in through the back door.
Two consequences worth stating outright, because filters are usually written assuming them without checking:
is_null on it, or by some other column's clause being true on the other
side of an or.is_null returns no rows with a Null in
that column, whatever operators and negations it uses.A NaN in a floating-point column is an ordinary value that happens to compare false against everything, so it follows IEEE rules rather than the three-valued rules above — and behaves as almost the opposite of a Null:
| expression | a row whose x is NaN |
a row whose x is Null |
|---|---|---|
x > 5, x >= 5, x < 5, x <= 5, x == 5 |
excluded (every IEEE comparison against NaN is false) | excluded (unknown) |
x /= 5 |
survives — NaN /= 5 is true |
excluded (unknown) |
not x > 5 |
survives — false negates to true | excluded (unknown negates to unknown) |
x is_not_null |
survives — a NaN is not missing | excluded |
x is_nan |
survives | excluded (unknown) |
x is_not_nan |
excluded | excluded (unknown) |
is_nan/is_not_nan say this directly, on a floating-point column:
call filt%add("flux is_not_nan") ! only rows whose flux is a real number
call filt%add("flux is_nan or flux is_null") ! only the rows with no usable value
Both are Kleene-honest about nullness — a Null row is unknown for either of them, exactly as it
is for a comparison. So x is_not_nan means "x is a real number", not "x is anything other
than a NaN": write x is_not_nan or x is_null when a missing value should count too. Nullness
stays governed solely by is_null/is_not_null.
Neither operator adds any expressive power the grammar lacked — under Kleene logic not (x >= 0 or
x < 0) already meant exactly x is_nan, since every non-NaN real satisfies precisely one of the
two disjuncts — but writing that out is easy to get wrong and hard to read, which is what these
two are for.
date, time and timestamp columnsA temporal column is compared against a double-quoted ISO-8601 literal, which is converted into the column's own stored unit:
call filt%add('obs_date >= "2024-01-31"')
call filt%add('obs_time < "12:30:00"')
call filt%add('obs_ts == "2024-01-31T12:30:00"')
timestamp column means
midnight of that date."2024-01-31T12:30:00.123456"
against a timestamp[ms] column is rejected, because that column cannot represent the value
being asked about. So is any literal with a time part against a date column.parquet_timestamp holds the stored epoch offset verbatim
(see Dates and times), so a literal is read as a civil date/time and
compared against the same stored instants a read returns.Only plain scalar columns can be filtered — naming a vector (col_size > 1) column in a rule
fails immediately with error stop when parquet_open_reader is called. So does naming a column
that doesn't exist in the file, or a rule with invalid syntax (an unbalanced parenthesis, a
dangling and, an unknown operator, an unquoted string value, a non-numeric value against a
numeric column, ...) — every rule is fully parsed and validated (column existence,
type-compatibility, and value parsing) right there in parquet_open_reader, before any of your
own code runs.
The benefit is mostly downstream: parquet_get_nrows and every column you read only ever reflect
the matching rows, so your own code loops over, allocates for, and processes far fewer rows when
the filter is selective — at the cost of a small transient memory bump while a column's decoded
array and its filtered result briefly coexist, before the unfiltered one is discarded. (The
nrows= shortcut mentioned above works here too — it reflects
the post-filter row count.)
A filtered reader consults each row group's own footer statistics (the per-column-chunk min, max and null count Parquet records) before reading anything, and skips the row groups those statistics prove cannot contain a matching row. The row groups that survive are then read and evaluated exactly, since min/max can only ever prove impossibility, never a match.
This is automatic, has no option to turn it on, and changes nothing about the result — the same rows come back in the same order, with the same nulls. The only difference is how much of the file was read to produce them, and it applies to the whole read, not just the filter's own columns: a payload column you read afterwards skips the same row groups.
type(parquet_filter) :: filt
call filt%add("id == 8123456")
call parquet_open_reader(reader, "big.parquet", filter=filt) ! 400 row groups
call parquet_read_column(reader, "flux", flux) ! may read 1 of them, not 400
How much this saves depends entirely on how the file is laid out. A column whose values are clustered by row group (written in sorted order, or naturally grouped like an observation date) prunes well; one whose values are scattered uniformly gives every row group the same wide min/max, and nothing can be ruled out. On a well-clustered column a highly selective read can be several times faster — roughly 5x on a measured 4M-row, 9-column file in 40 row groups — while a filter matching every row is unchanged.
parquet_close_reader(reader, print_stat=.true.) reports a screened: line when it skipped
anything, which is the way to tell whether it engaged on your data.
Some cases can never prune, and fall back to reading everything — correctly, just without the saving:
decimal,
half_float, or legacy int96, and likewise any column whose declared Parquet sort order is not
the one the screen reads for its type — signed for numbers and temporals, unsigned-byte for
strings. (These last two arise only from files written by other tools; this library's own writer
never produces them.);is_nan/is_not_nan, and a floating-point column under not or /= — Parquet excludes
NaN from min/max and records no NaN count, so for a float column the statistics can never prove
a comparison is false everywhere (see NaN is a value, not a Null);The two NaN-related entries are about the bounds being unusable, not about pruning being switched off: a row group in which the filter column has no non-null values at all is still skipped, because then every row is unknown for those operators whatever the values would have been.
Two things pruning deliberately does not change: parquet_get_num_row_groups still reports
every row group in the file, and a chunked read still visits every one of them (a skipped row
group reads as an empty chunk, exactly as a row group the filter emptied already did). Pruning is
an I/O optimization, not a view of the file.
One caveat worth stating: this trusts the file's own footer. A file whose statistics are wrong —
written by a tool that recorded bounds not matching its data — will give a wrong answer, in the
same way parquet_column_has_nulls already trusts the recorded null count.
parquet_reader_set_filter(reader, filt) applies a filter to an already-open reader, with exactly
the same result as having passed filter= to parquet_open_reader. It exists for callers that do
not own the parquet_open_reader call, and for filters that can only be built once the file's
schema or metadata has been inspected:
call parquet_open_reader(reader, "data.parquet")
if (parquet_column_exists(reader, "quality")) call filt%add("quality > 0.9")
call parquet_reader_set_filter(reader, filt)
It refuses, with error stop, in three situations: when the reader already has a filter
(combine the clauses into one parquet_filter instead — several %add calls are AND-combined);
when any column has already been read on that reader, whether as a whole column or through
parquet_read_column_chunk, since data already handed back
covers the unfiltered rows and could not be lined up with anything read afterwards; and when the
reader already has a sort, because a filter must be applied before one (see below). A reader
opened with sample_fraction= is fine — the filter applies on top of the sample, exactly as
passing both to parquet_open_reader does.
Naming row groups chooses a different engine. The call above takes two arguments, but it also accepts an inclusive 1-based row-group range, and optionally a physical row range inside it:
call parquet_reader_set_filter(reader, filt) ! whole file
call parquet_reader_set_filter(reader, filt, row_group_lo, row_group_hi) ! those row groups
call parquet_reader_set_filter(reader, filt, row_group_lo, row_group_hi, row_lo, row_hi)
Whether you name row groups at all — not which ones — chooses how the filter is evaluated.
Without them, every filter column is read whole-file in one batched pass and left decoded, so
reading one afterwards is free; that is the fastest option and the right default, at the cost of
one full copy of those columns in memory. With them, the expression is evaluated one row group at a
time and only the mask is kept, which is what makes a filtered read possible on a file larger than
memory. row_group_lo = 0 selects that bounded-memory engine over the whole file. Both integer
kinds are accepted throughout.
A scoped filter scopes the whole reader, not just a loop over those row groups — rows outside the range have no mask bits, so nothing later can return them. See Memory-bounded filtering with a row-group scope for the full treatment, including the row-range form and pairing it with a chunked loop.
parquet_sortkeyparquet_open_reader(reader, filename, sort_by=srt) returns the file's rows ordered by one or
more of its columns, instead of in physical file order. Every column read afterwards comes back in
that order, so there is no separate "sorted index" to carry around:
type(parquet_reader) :: reader
type(parquet_sortkey) :: srt
integer(int64) :: nrows
real(real64), allocatable :: ra(:)
call srt%add("ra asc")
call srt%add("dec desc")
call parquet_open_reader(reader, "cat.parquet", sort_by=srt)
call parquet_get_nrows(reader, nrows) ! unchanged: sorting reorders rows, never adds or removes
allocate(ra(nrows))
call parquet_read_column(reader, "ra", ra) ! already in (ra asc, dec desc) order
call parquet_close_reader(reader)
One key per %add call, applied in the order added — the first key is the primary one, later keys
break its ties:
| key text | meaning |
|---|---|
"ra" |
order by ra, ascending (the default) |
"ra asc" / "ra ascending" |
the same, spelled out |
"dec desc" / "dec descending" |
order by dec, descending |
"-dec" |
shorthand for "dec desc" |
"main.inner.age" |
a dotted struct-leaf path is a valid key |
Direction words are case-insensitive. Combining the - shorthand with an explicit direction
("-dec desc") is rejected rather than silently resolved, since it reads equally as agreement or
as cancellation.
Nulls sort last by default. Pass nulls_first=.true. on %add to move that one key's nulls
to the front instead — it is per key, not per sort:
call srt%add("quality desc", nulls_first=.true.)
NaN sits between the real values and the nulls, so an ascending float key gives values, then
NaNs, then nulls.
Placement is absolute: ordering a key descending reverses its values, it does not move its nulls
or NaNs. So "v desc" yields the largest value first and still ends with the nulls. This
reproduces Arrow's own sort ordering exactly, which means a result cross-checked against pyarrow
matches row for row.
Rows that tie on every key keep their original file order (the sort is stable).
Any scalar column can be a sort key: int32/int64, float32/float64, boolean, string,
and date/time/timestamp. A vector (col_size > 1) column has no single value per row to
order by and is rejected with error stop — from the schema, before any data is read. So is a key
naming a column the file doesn't have, or one whose text doesn't parse.
A sort has its own limits, each reported as a clean error stop rather than a crash: 16 keys
per parquet_sortkey, 320 characters per key, and 64 characters for the column name inside
one. The first two are published constants (parquet_max_sort_keys, parquet_max_sort_key_len) —
see Read-only limits.
If several threads each open their own reader over the same filtered or sorted file, they need not
each rebuild that work: parquet_reader_adopt_transform hands one reader's mask and permutation to
another for the cost of two atomic refcount increments — see
Thread safety.
A sort key column is always read whole: a global order needs every row, so there is no
row-group-scoped equivalent the way there is for filtering. That is the one place sorting costs
memory that filtering does not — but only while the permutation is being built. Once it exists the
key column is released, so sorting by a column you never read leaves nothing of it resident and
costs nothing to reorder. Reading the key back afterwards is therefore an ordinary read: it decodes
the column again and hands it back in sorted order like any other. Sorting itself never skips I/O —
the benefit is that your own code receives the rows already ordered. (Composed with filter=, the
key column is read over the surviving row groups only, since the filter's pruning applies to every
column read after it.)
prefetch=.true. is the one case that keeps the key resident, because there you have asked for
every column to be in memory anyway and releasing it would only make the prefetch decode it twice.
Give both and the filter runs first, then the sort orders the surviving rows:
call filt%add("mag < 20")
call srt%add("mag asc")
call parquet_open_reader(reader, "cat.parquet", filter=filt, sort_by=srt)
sample_fraction= behaves the same way. parquet_close_reader(..., print_stat=.true.) prints the
keys as applied, on their own sort: line.
Everything above is phrased as "the reader behaves as if the file only ever contained the surviving
rows", which leaves one question open: which rows are they?
parquet_get_physical_row_indices(reader, rows) answers it. It fills an
integer(int64), allocatable array with the 1-based physical file row number of every row the
reader currently returns, in the order it returns them — one entry per row, so size(rows)
matches parquet_get_nrows:
integer(int64), allocatable :: rows(:)
call parquet_open_reader(reader, "cat.parquet", filter=filt, sort_by=srt)
call parquet_get_physical_row_indices(reader, rows)
! rows(1) is the file row that sorted first among the filter's survivors
This is the only way to recover that mapping: it lives in the reader's own mask and permutation and
is not otherwise visible. Without a filter=, sample_fraction= or sort_by= it is simply
1, 2, 3, .... A parquet_table exposes the same information as an automatic parquet_row_index
column — see Row provenance — which is built on this procedure.
A sort permutation destroys row-group locality — sorted row 5 may come from row group 47 and row 6
from row group 3 — so anything row-group-scoped fails with error stop while a sort is active:
parquet_read_column_chunk and parquet_get_chunk_size.This is inherent to sorting, not a limitation to be lifted later: there is no coherent "row group N of the sorted output" to hand back. It is also the one respect in which sorting differs from filtering, which supports both — a filter only ever removes rows, so row groups stay contiguous, while a sort reorders them.
parquet_read_array_row_mode and parquet_read_array_element_mode still work, but they fall back
to reading the whole column, since the row they are asked for no longer belongs to any one row
group. row_index/elem_index then address the sorted result, as everything else does.
parquet_reader_set_sort(reader, srt) sorts an already-open reader, with the same result as
passing sort_by= to parquet_open_reader — the counterpart of
parquet_reader_set_filter, and for the same
reasons. Any column already decoded (by prefetch=, say) is reordered too.
It refuses, with error stop, when the reader already has a sort (add every key to one
parquet_sortkey instead) and when any column has already been read on that reader —
including through parquet_read_column_chunk, whose rows
were handed back in physical row-group order and cannot be reconciled with a reordering applied
afterwards.
Apply a filter before a sort, never after. A filter only removes rows and a sort then orders the
survivors, so a sort permutation is sized to the post-filter row count. Giving both to
parquet_open_reader gets this right automatically, as does set_filter followed by set_sort;
calling parquet_reader_set_filter on a reader that is already sorted is refused rather than
silently composing the two the wrong way round.
filt%remap_column_names(from, to) and srt%remap_column_names(from, to) rewrite, in place, the
columns a filter's rules or a sort's keys refer to: every reference to from(k) becomes to(k).
The two arrays are parallel and must be the same size.
type(parquet_filter) :: filt
call filt%add("mass > 1.0e12 and (redshift < 0.5 or flag is_null)")
call filt%remap_column_names(["mass ", "redshift"], ["m_200c ", "z "])
! the filter now reads: (m_200c > 1.0e12 and (z < 0.5 or flag is_null))
This exists for callers that build a filter in one column-name vocabulary and must apply it in
another. The main user is parquet_table, which lets a program write
filters in the table's own internal names and translates them into the file's physical names — see
Renaming a file's columns for
reading — but any
caller with the same split can use it directly.
Three properties are worth knowing:
name == "mass") is
never touched. Sort keys keep their direction and their nulls_first setting.from entry that no rule mentions is a
no-op, and a rule naming a column absent from from is left alone rather than rejected: only
the caller knows which names are supposed to exist. A rule that does not parse is also left
untouched, so the reader that applies it still reports the parse error, with the file named.from = ["a", "b"], to = ["b", "a"]
swaps the two columns; it does not rename a to b and then everything named b back to a.It does error stop in three cases: from and to differing in size, a replacement name longer
than the 64-character column-name limit, and — filters only — a rule that fitted
filter_max_rule_len in its original names but no longer does once renamed.
sample_fractionparquet_open_reader(reader, filename, sample_fraction=0.1_real64) keeps each row independently
with probability sample_fraction (Bernoulli sampling) — like filter=, it narrows what every
subsequent call sees (parquet_get_nrows, parquet_read_column, parquet_close_reader(...,
print_stat=.true.), ...), with no separate "sampled count" to track:
type(parquet_reader) :: reader
integer(int64) :: nrows
integer(int32), allocatable :: id(:)
call parquet_open_reader(reader, "data.parquet", sample_fraction=0.1_real64, sample_seed=42_int64)
call parquet_get_nrows(reader, nrows) ! already the post-sample row count
allocate(id(nrows))
call parquet_read_column(reader, "id", id) ! already just the sampled rows
call parquet_close_reader(reader)
sample_fraction (real(real64), optional): omitted, or >= 1.0, reads every row — the
current/default behavior. Must not be negative or NaN — either aborts immediately with error
stop. Exactly 0.0 deterministically yields zero rows (not just with overwhelming
probability).sample_seed (integer(int64), optional — write the literal as 42_int64): omitted, or
<= 0, draws a fresh seed from entropy — a different sample each time you open the file. A
positive value makes the draw reproducible:
the same sample_fraction/sample_seed pair always selects the exact same rows. Whichever seed
actually gets used (caller-supplied or entropy-drawn) is reported by
parquet_close_reader(..., print_stat=.true.) (a sample: fraction=... seed=... line) — read
it back from there to reproduce a run you didn't originally seed yourself. (A parquet_table is
slightly stronger: it settles one seed when it is opened and reuses it for every reader it opens
afterwards, so an unseeded sample stays fixed across a %clone — see Filtering, sorting and
checking rows as the file is
opened.
Two separate opens still draw independently either way.) The one exception is
sample_fraction = 0.0, which performs no draw at all and so reports seed=0 whatever you
passed — there are no rows to reproduce in that case.sample_fraction * nrows
rather than equaling it exactly (most noticeable on small files) — there is no "select exactly N
rows" mode.filter= share the same underlying mechanism: give both, and the filter is applied
on top of the downsample (a row must pass both to be kept). Consequently, sample_fraction <
1.0 behaves exactly as filter= does throughout, even with no filter= given at all —
parquet_read_column_chunk, parquet_read_array_row_mode and parquet_read_array_element_mode
all stay row-group-scoped, with row indices and chunk sizes referring to the surviving rows.sample_fraction, and unlike filter= there are no statistics for a random draw to consult.
The benefit is purely to your own code processing fewer rows afterward. (Combining
sample_fraction= with filter= does get the filter's own row-group pruning — the two compose,
and a sample only ever removes rows the filter already kept.)The draw is this library's own counter-based generator, and the rule is published rather than
internal — parquet_sample_algorithm names it, and it changes only when the rule does:
character(len=:), allocatable :: rule
rule = parquet_sample_algorithm ! "sample:bernoulli-u<p/philox/v1"
For physical row r (1-based — the row's position in the file as written), the rule is:
key = pf_random_key(seed, parquet_sample_label)
u = pf_random_at(key, 0_int64, r)
keep = u < sample_fraction
You can run exactly that yourself to work out which rows a seed will select, without opening the file. Three consequences are worth knowing:
parquet_sample_label keeps your own draws separate. Passing sample_seed=42_int64 does not
consume or collide with the values pf_random_at(42_int64, ...) gives you — the sample derives
its own stream through pf_random_key, so one seed can safely drive both.sample_fraction = 0.0 keeps nothing because u is in [0, 1) and no value is below zero,
not because of a special case.