parquet_tables is a high-level layer over the reader and writer: it presents a whole parquet
file as one parquet_table, hands its columns back as ordinary Fortran arrays, and writes a
table back out through the same parquet_schema you would use for parquet_open_writer. It does
not replace parquet_read_column/parquet_write_column — it drives them.
Use it when you want a file's columns available by name and would rather not manage a reader, a row count and one array per column by hand. Keep using the reader directly when you want precise control over exactly which reads happen and when.
Opening a table reads no column data. It reads the file's schema, so every column's name, kind and width is known immediately, and each column's values are read the first time something asks for them. A program that opens a 40-column file and uses two of them pays for two. See Laziness below for what follows from that.
program mean_mass
use parquet_tables
use iso_fortran_env, only: real64
implicit none
type(parquet_table) :: t
real(real64), allocatable :: mass(:)
call parquet_open_table(t, "catalogue.parquet")
call t%get("mass", mass)
print *, "rows:", t%nrows(), " mean mass:", sum(mass) / size(mass)
end program mean_mass
useuse parquet is enough for everything on this page. It re-exports the whole library, so a
program that mixes a table with a kind constant, a schema, a filter and a timestamp needs exactly
one use statement:
use parquet ! parquet_table, PK_FLOAT64, parquet_schema, parquet_filter, ... all in scope
The example above says use parquet_tables instead, and that still works: each layer remains its
own module and can be named individually when you want a narrower import (it compiles marginally
faster, and it documents which layers a source file actually depends on). The table below says
which module each name lives in, for when you want that.
| you write | its own module |
|---|---|
parquet_table, parquet_open_table, parquet_new_table, parquet_write_table, parquet_table_row, parquet_slice/parquet_slice_range/parquet_slice_list, parquet_table_row_group_bounds, RES_EMPTY/RES_FULL (and RES_PARTIAL, reserved and never returned today) |
parquet_tables |
the PK_* kind constants, parquet_kind_name |
parquet_columns |
parquet_schema, parquet_parse_maml, parquet_filter, parquet_sortkey, parquet_read_qc |
parquet_io (a facade over the internal parquet_core) |
parquet_string_column |
parquet_strings |
parquet_date, parquet_time, parquet_timestamp |
parquet_temporal |
parquet_core — which parquet_io re-exports and which older material may name directly — is the
one module you should never import: it is an internal implementation module that may be renamed or
restructured in any release. Every module in the table above, use parquet included, is covered by
the library's API stability promise; see
Choosing a module for what each one costs to compile
against.
%get is the one to reach for first. It copies the column into an allocatable array of
whatever kind you declared, widening an int32 column into an integer(int64) array and a
float32 column into a real(real64) array on the way. So you can ask for the type you want to
work in without knowing what the file happened to store:
real(real64), allocatable :: v(:)
call t%get("flux", v) ! works whether the file stored float32 or float64
%get does not change what the table holds. The widening happens on the way into your
variable; the column keeps the kind the file stored, and asking for it again in another kind
converts again. The call that changes the stored kind is %cast, and
the two together are the way to read a file column into a type you choose:
call t%cast("flux", PK_FLOAT64) ! the table now holds float64, whatever the file stored
call t%get("flux", v) ! ... and this is a copy, not a conversion
On a column nothing has read yet, %cast costs nothing extra: the read decodes straight into the
kind you asked for, in one pass.
%col is the zero-copy alternative. It points a Fortran pointer straight at the table's own
storage: no copy, and writes through it change the table.
real(real64), pointer :: p(:)
call t%col("flux", p)
p = p * 2.0_real64 ! modifies the column in place
Four things to know about %col:
%kind first if you do not
already know the type (see below). A mismatch is a hard error, not a silent conversion. To point
at a column in the kind your code wants rather than the kind the file happened to store, convert
the column first with %cast — a column already of that kind is
left alone, so the call is safe to make unconditionally.%cast, %compact, %reserve, and
the two that give a column's values back and read them again, %evict_column and %reload.
Re-fetch the pointer after any such call. Fortran cannot detect a stale pointer and neither can
this library, but %generation() can tell you whether anything structural happened:g = t%generation()
call some_procedure(t) ! might mutate it
if (t%generation() /= g) call t%col("flux", p) ! re-fetch; the old p may be stale
The counter is deliberately conservative — every column- and row-structural call bumps it, whether or not it actually moved anything — so a change in it means "re-fetch", not "definitely invalidated". A call that changes nothing does not bump it.
One bump is worth knowing about because the call does not look structural:
parquet_write_table advances the counter when it gives back a
column it had to read (its default). Nothing you can hold a pointer to is ever given back — taking
a pointer materializes the column, and only columns the write itself materialized are released —
so after a write the counter may have moved while every pointer you hold is still good. Re-fetch
or ignore it; do not read the advance as evidence of damage.
Two more rules for a pointer you are holding:
PK_STRING/
PK_STRING_VEC are stored as a packed variable-length buffer, so there are no fixed row slots
for a Fortran array to alias — but %col will hand back a type(parquet_string_column),
pointer to the store itself. Read it and edit its values in place; do not change its length or
element count through the pointer, since the column's own row count is kept separately. See
String columns in a table.bench/benchmark_table.sh's access mode, z = xp + yp through two %col pointers runs
within 1% of the same expression over two allocatables. There is no pointer penalty to trade
against the copy %get would have made.You do not need to declare the table target to use %col. The pointer refers to the table's
internal heap storage, not to the table variable itself.
%get and %colUse %col unless you want a copy. Computing through the pointer is within 1% of computing
over an array you own, so there is no break-even to work out and no number of passes at which
%get starts winning: %col avoids a full copy of the column and gives up nothing measurable for
it.
%get is the right call when a copy is what you are after:
%get hands back a detached
array; writes through a %col pointer go straight into the column.%get widens on the way out. (If the column
itself should change kind, %cast it and keep using %col.)%col
pointer does neither.bench/benchmark_table.sh's access mode measures both on your own machine and column size.
%get, %col, %get_slice and %set all take an optional is_valid=, .true. where a value
is present:
call t%get("flux", v, is_valid=ok) ! values and their validity in one call
call t%set("flux", v, is_valid=ok) ! ... and back again: .false. entries become null
The mask has the same shape as the values it describes. A scalar column's is ok(:), one entry
per row; a vector column's is ok(:,:), shaped (width, nrows) exactly like the values, with
one entry per element:
real(real64), allocatable :: v(:,:)
logical, allocatable :: ok(:,:)
call t%get("spectrum", v, is_valid=ok) ! ok is (width, nrows)
if (.not. ok(3, 7)) print *, "element 3 of row 7 is missing"
There is no one-entry-per-row form for a vector column: a vector column's nulls are per element, and a mask that could not say which element is missing would be answering a different question.
Four things to know:
%set it goes the other way. A plain %set drops the column's nulls outright; passing
is_valid= writes the values and then marks the .false. entries null, which is the only way to
replace a column and its nulls in one call. A mask of the wrong shape is an error naming both.%col it is a snapshot, not an alias. Validity is a packed bitmap, so there is no
logical array in the column for a pointer to refer to. Writing through the value pointer
afterwards does not update the mask you were given.%get_valid_mask(name, mask), which is the way to ask for validity
on its own without copying the values too — and which accepts either shape: a rank-1 mask is
the per-row summary, a rank-2 one the true per-element state. See
Null values, and changing them for the two
side by side.logical is four bytes, so a (width, nrows) mask costs
4 * width * nrows bytes — for a width-100 column of a million rows, 400 MB. Ask for it when you
want the whole thing; use %is_null(name, i, e) for a few elements.%kind returns a PK_* constant, and parquet_kind_name turns one into text for a message:
select case (t%kind(name))
case (PK_FLOAT64); call t%col(name, p64); print *, sum(p64)
case (PK_INT32); call t%col(name, p32); print *, sum(p32)
case default
call parquet_kind_name(t%kind(name), kname)
print *, "unhandled kind: ", kname
end select
If all you want is the numbers, call t%get(name, arr_f64) is shorter and widens for you — the
dispatch above is only worth it when you need the zero-copy path.
The third option removes the question instead of answering it: %cast
converts the column to the kind your code is written for, so one branch covers every numeric
column the file might have held.
call t%cast(name, PK_FLOAT64) ! no-op if it is float64 already
call t%col(name, p64) ! one path, whatever the file stored
%cast converts only between the numeric kinds, so %kind is still the way to find out whether a
column is numeric at all.
| call | answer |
|---|---|
t%nrows() |
rows every column holds |
t%nrows_unfiltered() |
rows before filter=/sample_fraction= — the slice's length, or the file's; 0 for a table built in memory |
t%row_group_extent() |
rows in the row groups this table covers, i.e. what reading it decodes; 0 for a table built in memory |
t%ncols([resident_only]) |
number of columns; resident_only=.true. counts only the ones already read |
call t%column_names(names [, resident_only]) |
every column name, in file order; resident_only=.true. lists only the ones already read |
t%has_column(name) |
whether a column of that name exists |
call t%require_columns(names) |
aborts unless the table has every one of these — naming every missing one, not just the first |
call t%missing_columns(names, absent) |
which of these the table does not have, as a packed array; zero-size when it has them all |
t%column_index(name [, found]) |
its 1-based position among the table's columns, or 0 when absent |
call t%column_name(j, nm [, found]) |
the name of the column at 1-based position j |
t%kind(name) |
its PK_* kind (PK_NONE if unreadable) |
t%width(name) |
values per row: 1 for a scalar column, the element count for a vector one |
call t%unit(name, u) |
a column's unit, from the read-in MAML's unit: key |
t%residency(name) |
RES_FULL once read, RES_EMPTY before that (and for an unreadable column) |
t%is_supported(name) |
whether its physical type is one this library can read (see the note below for plain LIST columns) |
t%is_null(name, i [, e]) |
whether row i of that column is null, or element e of it |
t%has_nulls(name [, found]) |
whether the column holds any null — from the file's footer if it has not been read |
call t%get_valid_mask(name, mask [, found]) |
its validity as a logical array, .true. where a value is present — rank-1 for one entry per row, rank-2 for the (width, nrows) per-element state |
t%generation() |
a counter bumped by every structural change (see pointers) |
t%is_detached() |
whether a row-changing operation has cut the table loose from its file |
call t%filename(f) |
the file this table was opened from, or "" for one built in memory |
call t%get_file_metadata(key, value, [found]) |
one key from the source file's metadata — an error on a table built in memory, which has no file to ask |
(An argument in square brackets is optional — [found] above means found may be omitted.
The brackets are notation for this documentation, never something you type.)
%require_columns is the one to reach for when your program needs certain columns to exist.
A loop over %has_column reports the first name that is missing; this reports every one, in a
single message, so a program run against the wrong file is told what is actually wrong with it
rather than being fixed one name at a time:
call t%require_columns("ra,dec,mag") ! aborts naming every one of the three that is absent
%missing_columns(names, absent) is the non-aborting half: it hands back the absent names in a
character(len=:), allocatable array, zero-size when the table has them all, so a program can
report or recover for itself. Both take names as an array or as one separated string, exactly as
%materialize does (see Laziness). Neither takes found= — and
neither needs it, since reporting absence is what both are for.
%is_supported answers about the column's TYPE, and for one type that is not the same as "the
read will succeed". It reads no data. For everything except a plain LIST/LARGE_LIST the
schema settles the question, so the answer does predict the read. A plain LIST carries no
width in the schema, though — unlike the fixed_size_list this library's own writer emits — so
whether the column is a usable vector column depends on the data:
list<int32> whose every row holds 3 elements is an ordinary vector column of width 3,
and reads normally;list<int32> whose rows differ in length has no single width and is rejected when it is read.%is_supported reports .true. for both, because the element type is readable in both and it
cannot tell them apart without reading. Use %width(name) when you need the stronger answer: it
resolves a deferred width for real (footer screen, then a row-group scan) and so distinguishes the
two. See Reading parquet files for how that resolution works.
%kind, %width, %unit, %residency, %is_supported, %has_nulls and %is_null also
accept a 1-based column position wherever they accept a name, so a sweep over every column does
not have to copy a name out just to ask about it:
do j = 1, t%ncols()
call t%column_name(j, nm)
call parquet_kind_name(t%kind(j), kname)
print *, nm, kname, t%width(j)
end do
%column_index(name) and %column_name(j, nm) are inverses, and a position is only meaningful
against the table's current width: %drop_column, %add_column and the other column-set
changes renumber the slots, so re-derive positions after one rather than holding them across it.
An out-of-range position is an error, and reports through found= exactly as a missing name does.
These are metadata queries — they do not read a column's values, with the one documented
exception %kind/%width already carry (a plain LIST column from a non-Arrow writer, whose
width is a property of the data). Positions address the table's own columns, so they are
unrelated to the element index within a vector column.
found= is available on the calls that look a column up by name, and it turns a missing
column from a hard error into a quiet report: %kind, %width, %unit, %residency,
%is_supported, %get, %col, %prefetch and %reload.
call t%get("maybe_absent", v, found=ok)
if (.not. ok) print *, "column not in this file"
On a reported miss the reader leaves an empty result, never an undefined one, so a program
that ignores found gets nothing rather than stale data or something it must not touch. That one
rule covers every reading form: %get, %get_slice and %get_element leave a zero-length array
(a defined zero, blank or null element where the receiver is a scalar), and %col a null
pointer.
Every procedure that looks an existing column up by name accepts it, mutators included —
%set, %is_null, %set_element, %set_null, %clear_null, %get_slice, %set_slice,
%get_element, %has_nulls, %get_valid_mask, %compact_validity, %drop_column,
%rename_column, %copy_column and %cast. On a mutating call found=.false. means nothing
was changed: the column is looked up before anything is written. %row(i) is the exception,
since it names no column, and %get_file_metadata's found reports a missing key rather than
a missing column.
Three name-taking calls are deliberately outside this: %add_column creates a column rather
than looking one up, and %require_columns/%missing_columns report absence as their whole
purpose (see above).
On %prefetch's array form, found is the conjunction: it comes back .false. if any name
was missing, and the names that do exist are still read. A column whose type this library cannot
read is an error for %prefetch and %reload whether or not found= is present — asking to read
it is a mistake rather than a miss.
A column is read on its first value access. That is anything that has to see the values:
%get, %col, %set, %is_null, %get_slice, a row handle's %get/%is_null, the cell
mutators %set_element/%set_null/%clear_null/%compact_validity, and %copy_column.
parquet_write_table counts too — it reads whatever its schema names and the table has not read
yet. %cast is the one that can go either way: on a column nothing has read it only records the
kind to decode into, and the read still happens later (see
Changing a column's type). Everything else (%nrows, %ncols,
%column_names, %kind, %width, %unit, %has_column, %residency, %is_supported,
%filename) is answered from the schema and reads nothing.
call parquet_open_table(t, "catalogue.parquet")
print *, t%kind("mass"), t%residency("mass") ! PK_FLOAT64, RES_EMPTY -- nothing read yet
call t%get("mass", mass) ! now it is read
print *, t%residency("mass") ! RES_FULL
Reading a column costs one pass, not two. The table takes over the array it just read rather than
copying into storage of its own, and it asks the file's own statistics whether the column contains any
Null before building a validity mask — for a Null-free column, which is most columns of most files, no
mask is built at all. parquet_column_has_nulls exposes that same question if you want it directly.
Four calls control it explicitly:
| call | does |
|---|---|
call t%materialize(names, [found]) / call t%prefetch(names, [found]) |
read those columns now |
call t%materialize_all() |
read every column not yet read |
call t%reload(name, [force], [found]) |
re-read one column from the file, discarding local %set edits |
call t%evict_column(name, [force], [found]) |
release a column's values, keeping the column |
%materialize and %prefetch are the same call under two names — they bind the same
procedures, so they cannot behave differently. The pair to reach for is
%materialize(names)/%materialize_all(): facing a mutation that detaches the table, the
instinct is to reach for the definitive-sounding %materialize_all(), and if only four columns
were wanted that reads the whole file — silently, visible only as time and memory. Name the
columns.
names may be an array or one string, with commas and/or semicolons between the names:
call t%materialize("ra,dec,mag") ! one string, separators either way
call t%materialize([character(len=3) :: "ra", "dec", "mag"]) ! or an array
Prefer the string. The array constructor needs every element padded to one declared length, and guessing that length too short does not fail — it silently truncates a name, which then surfaces as "column not found" or, worse, as a different column. Blanks around a name are trimmed and an empty token is ignored, so a trailing separator is harmless. A column whose own name contains a comma or a semicolon is reachable through the array form only.
%prefetch's array form is not just a loop: it reads the named columns in one pass, which
matters for struct leaves. The reader decodes a struct as one array shared by all its leaves,
and the table frees that array as soon as the column it was asked for is stored — so touching
main.a, then main.b, then main.c one at a time decodes the struct three times, while
call t%prefetch(["main.a", "main.b", "main.c"]) decodes it once. Reading them separately is
never wrong, only slower.
Naming the struct itself does the same for all of its leaves, which saves listing them:
call t%prefetch("main") ! every leaf under main., in one pass
call t%prefetch(["main.a", "main.b"]) ! ... or just these two
A real column of that exact name always wins over the prefix reading, so a file with a column
literally called main is unaffected. A name matching neither is a missing column, reported the
usual way.
Calling either twice is well defined, and they differ. %prefetch on a column that is already
resident does nothing — so prefetching a list of columns repeatedly, or prefetching one another
call has already read, costs a name lookup and no I/O. %reload re-reads every time by design:
it discards what is in the store and goes back to the file, which is the whole point of it, so two
%reload calls are two reads. On a column nothing has read yet the two have the same outcome, and
only then.
%evict_column is %prefetch's counterpart and the way to give a column's memory back
without losing the column. An evicted column still appears in %column_names, still answers
%kind/%width/%unit, and is read again on the next touch — where %drop_column removes it
for good. Evicting is only allowed where the values can be read back: a column built with
%add_column, and any column of a detached table, is an error rather
than silent data loss. Evicting a column that is not resident is a no-op.
A third case is refused for the same reason: a column you have written into. The values in it
are not in the file, so evicting would put the file's own values back on the next read and your
edits would be gone with nothing to notice. Pass force=.true. when that is what you mean:
call t%get("flux", flux)
call t%set("flux", corrected)
call t%evict_column("flux") ! -> error: this column holds values you wrote
call t%evict_column("flux", force=.true.) ! -> accepted; the next read returns the FILE's values
Eviction is user-driven only. Nothing in this library evicts on its own — there is no LRU and no memory budget — so what a table holds stays predictable from the calls you wrote.
The table tracks whether a column's values came from the file or from you, and
t%is_user_populated(name) reports it. Every value-setting call marks the column — %set,
%set_element, %set_slice, %set_null, %add_column, and a row or column handle's %set.
Writing through a %col or %ref pointer does not, and cannot: the library hands out a
pointer and never sees what you do with it, so a write and a read are indistinguishable to it.
The protection is therefore precise rather than total — eviction will not silently discard values
you %set, which is not the same as eviction is safe. For the pointer case, say so yourself:
call t%col("flux", p)
p = corrected
call t%set_user_populated("flux", .true.) ! now %evict_column and %reload protect it too
call t%set_user_populated(name, flag [, found]) claims a column's values as yours (.true.) or
releases the claim (.false.), and the column handle carries the same pair —
call c%set_user_populated(flag) and c%is_user_populated() — which is usually the more
convenient spelling, since %ref is a handle method to begin with. Claiming a column that holds no
values is an error, because there is nothing there to protect; releasing is always allowed.
Two behaviours worth knowing, both of which you can watch through %is_user_populated:
%reload clears the claim, because the column then holds the file's values again —
so a second %reload needs no force=.%cast does not touch it either way. A cast changes a column's kind, not whose values
those are, so a cast column still evicts freely, and a %set you made before casting keeps its
claim.%print_stat marks a claimed column with a trailing * and prints a one-line legend, so a report
shows at a glance which columns would not survive a re-read.
The one place a table holds slightly more than its rows need is after appending: storage grows
geometrically, so an appended-to table can carry up to 1.5x its rows' worth until
%compact() releases it. A table read from a file, or one that has only been
filtered or sorted, is allocated exactly to size.
%reload only applies to a column that came from a file: reloading one built with %add_column
is an error, since there is nothing to reload it from, as is reloading anything once the table has
detached. It re-reads into the kind the column currently has, so a
%cast is not undone by a reload — only local value edits are, and force=.true. is required
to discard them, exactly as for %evict_column above. That is deliberate: discarding your edits
is what %reload is for, but it is a strong enough action to be worth saying rather than
assuming, and it keeps the two calls under one rule instead of two. On a column you have not
written into, %reload needs no keyword at all.
t%prefetchand the reader-levelparquet_prefetch_columnsare different things. The table's reads a column into the table's own store; the reader's warms Arrow's side of the read.
Reading a not-yet-resident column, growing a table, and nulling elements from several
threads each have their own rules — see
What a parquet_table allows concurrently
for the enforcement table and the worked patterns for all three.
t%row(i) returns a lightweight, non-owning handle on a single row — the natural thing to hand
to a procedure that works on one row:
type(parquet_table_row) :: r
real(real64) :: m
r = t%row(42)
call r%get("mass", m) ! scalar kind -> scalar
call r%get("flux", spectrum) ! vector kind -> allocatable rank-1 array
print *, r%index(), r%is_null("mass")
What a row handle can do:
| call | answer |
|---|---|
call r%get(name, value) |
one column's value in this row — scalar for a scalar column, allocatable rank-1 array for a vector one |
call r%set(name, value) |
writes that value into the table — a handle is a view of it, not a copy |
call r%get(c, value) / call r%set(c, value) |
the same, with the column named by a column handle instead of a string — no name lookup |
call r%ref(name, p) |
a pointer to this row's storage: zero copy, writable, exact kind |
r%is_null(name [, e]) |
whether this row of that column is null, or element e of it |
r%index() |
which row this is, in the table's own numbering |
r%is_valid() |
whether the handle is still usable — see below |
%set is exact-kind (a write never widens) and clears that row's null, exactly as
%set_element does. %ref gives a scalar pointer for a scalar column and a pointer to the whole
vector for a vector one; the two string kinds have no %ref, since a packed variable-length store
has no fixed slot to point at. A %ref pointer carries the same lifetime rule as %col's — see
Two ways to reach a column.
%get on a handle widens exactly as the table's own does, and triggers the same lazy first
touch, so a handle can reach a column nothing has read yet. The index must be a row the table has,
and the table it came from does not need the target attribute.
A row handle does not survive a structural change, and says so. It stamps the table's
%generation() when it is made and refuses once the two differ, so a handle left over from before
a %sort_by, a %filter_rows, an %append, a dropped column or a %compact aborts with a
message naming the remedy rather than quietly reading whatever now sits at that index. %is_valid()
is the non-aborting way to ask:
r = t%row(42)
call t%sort_by("mass")
if (.not. r%is_valid()) r = t%row(42) ! re-fetching costs one call
The rule is deliberately conservative — some mutations a particular handle could have survived are refused anyway — because one total rule is easier to rely on than a list of exceptions. Handles are cheap, so the shape to reach for is re-fetching inside the loop rather than working out which changes are safe.
The handle has to be a variable — call t%row(42)%get("mass", m) does not compile. That is a
constraint of Fortran itself, not a gap in this library: the leftmost part of a data reference
cannot be a function reference, so neither the call above nor t%row(42)%index() is legal, and no
library change can make them so. Write the two steps:
type(parquet_table_row) :: r
r = t%row(42)
call r%get("mass", m)
For a single cell there is no need for a handle at all — call t%get_element("mass", 42, m) is
the one-call form (see Replacing values).
t%column("mass", c) gives a handle on one column — the mirror image of t%row(i). A row
handle fixes the row and names the column on every access; a column handle fixes the column and
names the row:
type(parquet_table_col) :: c
real(real64) :: m
call t%column("mass", c)
do i = 1, t%nrows()
call c%get(i, m)
...
end do
The point is what it removes. %get_element(name, i, v) looks the column up by name on every
call, and that lookup is 52–77% of what the call costs — measured on four toolchains across
three machines, which is why this is quoted as a range rather than a number. A handle resolves it
once. A per-cell loop over a handle runs several times the name form's throughput, and so does
a realistic four-column loop with arithmetic in the body; measure your own case with
bench/benchmark_table.sh if the margin matters to you.
t%column(j, c) takes a 1-based position instead of a name, which is what makes a
do j = 1, t%ncols() loop work. Both forms take [found], and report a missing name or an
out-of-range position the same way every other lookup does.
What a column handle can do — brackets mark optional arguments:
| call | answer |
|---|---|
call c%get(i, value) |
row i's value, widening exactly as %get_element does |
call c%set(i, value) |
writes it; exact kind, and the write clears that row's null |
call c%get(i, e, value) / call c%set(i, e, value) |
one element of row i of a vector column, with no array allocated |
c%is_null(i [, e]) |
whether that row — or element e of it — is null |
call c%set_null(i [, e]) / call c%clear_null(i [, e]) |
mark or unmark it |
call c%ref(p [, is_valid]) |
the same pointer %col gives, without the lookup |
c%index(), c%kind(), c%width(), c%residency() |
this column's position, kind, values per row, residency |
call c%name(nm), call c%unit(u) |
its name and unit string |
c%is_valid() |
whether the handle is still usable |
c%get(i, e, value) is new capability, not a faster spelling. There has never been a way to
read one element of a vector row without materialising the whole row — %get_element on a vector
column allocates a width-long array on every call. This reads the one element, and is
appreciably cheaper than reading the row it avoids building — by how much depends on the
column's width and on your compiler.
A handle is a view: %set through it changes the table, and the change is visible through the
name form immediately. %ref hands back exactly the pointer %col does, with exactly the same
rules and the same warning — see Two ways to reach a column.
A column handle does not survive a structural change, on the same terms the row handle does:
it stamps %generation() when it is made, refuses once they differ, and %is_valid() asks without
aborting. Dropping a column renumbers the slots above it, so a handle taken beforehand would
otherwise name a different column — valid values, wrong answer, no diagnostic. That is the case
the stamp exists for.
| you want | use | why |
|---|---|---|
| the fastest possible loop; kind known at compile time, no widening | %col — and hoist the pointer out of the loop |
a plain array read, no call at all |
| a guarded per-cell loop: kind decided at run time, a string column, widening, or validity-aware access | a column handle | one resolve for the whole loop, and every guard still runs |
| one cell, or a few | %get_element(name, i, v) |
nothing to hoist, nothing to keep valid |
Two traps, both of which only appear at scale:
%get_element on
both. If the loop changes the table's structure, do not use a
handle at all — use the name form, which resolves afresh each time.t%column(j, c) READS column j. Making a handle resolves the column, which triggers the
same lazy first touch any value access does. A do j = 1, t%ncols() loop that builds a handle
just to print %name() and %kind() therefore reads the whole file. For a metadata sweep use
the by-position queries instead — t%kind(j), t%width(j), t%column_name(j, nm) — which
answer from the descriptor and read nothing. See
Asking about a column by position.%get_slice copies a selection of rows rather than the whole column. Build the selection with
parquet_slice_range (start:stop:step) or parquet_slice_list (an explicit gather):
type(parquet_slice) :: s
s = parquet_slice_range(1, 10, 2) ! rows 1, 3, 5, 7, 9
call t%get_slice("mass", s, m)
s = parquet_slice_range(1000) ! row 1000 to the end
s = parquet_slice_range(10, 6, -2) ! counting down
s = parquet_slice_list([9, 2, 2, 15]) ! in that order, repeats allowed
stop defaults to the table's last row and is resolved when the slice is used, so one slice
object works on tables of different lengths. step defaults to 1 and must not be zero; every
selected row must exist. %get_slice widens like %get, and a vector column keeps its
(element, row) shape. There is no pointer form: a strided or gathered selection is not
contiguous, so %col (the whole column) remains the zero-copy path.
%set_slice writes a selection back, taking the same selection object in the same order:
call t%get_slice("mass", s, m)
m = m * 2.0_real64
call t%set_slice("mass", s, m) ! the same rows, in the same order
The array must have exactly one value (or one vector) per selected row, and unlike %get_slice it
does not widen — a copy into the table is exact-kind, as %set is. It also takes
is_valid= (one entry per selected row) and modify_nulls=.
A selection is taken in the order given, duplicates included — on both halves. A
parquet_slice_list may name rows out of order and may name the same row more than once, so
%get_slice returns one entry per selection rather than per distinct row, in the selection's
order rather than the table's. %set_slice applies the writes one after another rather than
merging or de-duplicating them, so a row named twice ends up holding the last value written to
it:
s = parquet_slice_list([4, 2, 4])
call t%get_slice("mass", s, m) ! m is [row 4, row 2, row 4] -- three entries
m = [10.0_real64, 20.0_real64, 30.0_real64]
call t%set_slice("mass", s, m) ! row 4 ends up 30.0, not 10.0
That is what makes %set_slice the exact inverse of %get_slice whenever a selection names each
row once, which is the case for every parquet_slice_range.
A PK_STRING column is reached by copy, in either of two shapes, and which one you declare picks
the specific:
character(len=:), allocatable :: names(:) ! fixed width, sized to the longest value present
type(parquet_string_column) :: packed ! compact: offsets + data + validity
call t%get("name", names) ! a null row comes back blank -- gate on %is_null to tell them apart
call t%get("name", packed) ! keeps each value's own length, and its nulls with it
Four things follow from strings having no fixed-width storage:
%col gives a pointer to the packed store, not to an array. call t%col("name", sp) with
type(parquet_string_column), pointer :: sp aliases a PK_STRING column's own store: reading
and in-place value edits go straight to the table. Changing how many elements it holds does not
— the column's row count is kept separately and would stop matching.parquet_string_column is a first-class value in both directions. %get, %set and
%add_column all take one (%set and %add_column copy it in, so the caller's own column and
the table's do not share storage afterwards). The character(len=*) forms remain available for
when a fixed-width array is what you have. %set takes the same is_valid=, modify_nulls=
and found= here as it does for a plain array — and because this source carries nulls of its
own, modify_nulls=.false. gives the union: a row stays null if it was null in the table or
is null in the column you are writing.character(len=*) array shares one declared length, so a shorter value is blank-padded by
Fortran and those blanks carry nothing you could have meant — %add_column, %set and
%set_slice therefore store "ab" from a character(len=32) array as two characters, not
thirty-two. %set_element and a row handle's %set take a scalar, whose length is exactly
what you wrote, so they store it verbatim. This is why %get into a
character(len=:), allocatable comes back sized to the longest real value rather than to the
width you put in:character(len=32) :: src(3) = ["ab", "cde", "f"]
character(len=:), allocatable :: back(:)
call t%add_column("tag", src)
call t%get("tag", back) ! len(back) == 3, not 32
%get_slice offers all three — parquet_string_column, character(:) and the
(element, row) rank-2 form — and a row handle's %get hands back a
character(len=:), allocatable scalar for a PK_STRING column, or a rank-1 array of them for a
PK_STRING_VEC one."ok " comes back as "ok" blank-padded to the array's width, which is indistinguishable from
the value "ok":character(len=:), allocatable :: tags(:,:) ! (element, row)
call t%get("tags", tags) ! width 3, say
print *, "[", tags(1, 1), "]" ! [ok ] -- padded to the widest value
print *, "[", trim(tags(1, 1)), "]" ! [ok] -- any trailing blanks are gone
Scalar string columns do not have this problem when read into a parquet_string_column, which
keeps each value's own length; there is no such path for a vector one.
See Compact string columns with parquet_string_column for the type itself.
%col hands back a typed pointer and insists on the exact stored kind, which is awkward when the
code knows what type it wants to work in but not what type the file happens to hold. %cast
settles that: it converts the column itself, so the %col call afterwards is the one the code was
written for.
call t%cast("mass", PK_FLOAT64) ! whatever it was stored as, it is float64 now
call t%col("mass", mass) ! ... so this pointer is the right kind by construction
A column already of the requested kind is left alone, so this is safe to call unconditionally.
The conversions allowed are exactly those the reader and the writer already perform between
numeric kinds — see Reading a column into a different numeric
kind. In short:
int32 ↔ int64, float32 ↔ float64, and either integer kind to or from either real kind,
scalar or vector. Anything else is refused: there is no conversion between a logical, string or
temporal column and a numeric one, and none between a scalar column and a vector one (that would
change the column's width, which is a reshape rather than a conversion).
Two kinds of loss are treated differently, again matching the read path:
float64 too large for float32,
which would otherwise quietly become infinity.float64 → float32, and a large integer into a real kind, round
the way a plain Fortran conversion would. Pass exact=.true. to make those an error too.%cast keeps the column's nulls, its unit and its row count. It does not keep a pointer:
A pointer from
%coldoes not survive a%castof that column. The conversion replaces the column's storage, so take the pointer again afterwards. Fortran cannot detect this.
A cast asked for before anything has read the column is carried out by the read itself: the
column is decoded straight into the target kind in one pass, rather than being read and then
converted. This is invisible except for one thing worth knowing — the reader performs the
conversion in that case, and the reader does not check for the precision loss described above, so
exact=.true. reads the column first rather than deferring.
To keep the original column as well, copy instead of casting:
call t%copy_column("mass", "mass_f32", PK_FLOAT32) ! adds a column, leaves "mass" alone
call t%copy_column("mass", "mass_backup") ! no target kind: a plain copy
%copy_column with no to_kind copies any column — string, temporal and vector columns
included — and with one, converts by %cast's rules. It differs in one deliberate way: exact
defaults to .true. here, because a copy is usually taken in order to keep something, so a value
that would not survive the round trip is refused rather than truncated.
call t%print_stat() prints one line per materialized column to standard output — its kind
and unit, width, null count and min/max — under a header saying how many of the table's columns
those are:
parquet_table: catalogue.parquet
rows: 6 columns: 18 (3 materialized)
column kind width nulls min max
s_i32 PK_INT32 1 0 1 6
s_f64 PK_FLOAT64 [Msun] 1 1 1.75000 10.5000
s_str PK_STRING 1 0 a p
all=.true. lists every column, with - where one that has not been read has nothing to report.
Four things worth knowing:
LIST column prints as pending rather
than being measured — a diagnostic that changes what it is diagnosing is worse than one that
admits it does not know.logical column reports T:<n>/F:<n> instead of an ordering, and a vector column's
statistic is over all of its elements, flattened. An all-null column has no min or max and
prints -.%add_column, which
has no footer at all.This is still a deliberately narrow version of the table layer.
sort=. Every other read-time transform applies to a slice as
it does to a whole file (see A slice with a filter, a sample or
qc); a sort reorders rows across the whole file, so it is
refused there and has to be done in memory afterwards with %sort_by.b = a is a hard error, not a silent alias — the column store lives behind a pointer, and
a shallow copy would leave two tables sharing (and double-freeing) one store. Use %clone.%cast and %copy_column convert only between the numeric kinds (%copy_column with no
target kind copies any kind at all). Logical, string and temporal columns have no conversion,
and a conversion never changes a column's width.%append null-fills a missing column; a per-column default value is not available. Fill
the batch explicitly if you want something other than nulls.%unit reports what a read-in MAML's unit:
key declared (see Units) or what %add_column(unit=) stored, and "" otherwise.
There is no unit conversion anywhere in this library.One topic a reader may expect on this page lives with the generator instead:
Extending parquet_table with your own type
— a generated table type is the automated version of exactly that.
The table keeps its own Fortran copy of each column it reads and releases the reader's decoded
Arrow buffers as it goes, so a fully loaded table holds roughly one copy of the data rather than
two — and a table whose columns were never touched holds nothing at all.
bench/benchmark_table.sh measures this directly (see CONTRIBUTING.md); note that resident set
size does not show it, because Arrow's memory pool keeps freed pages rather than returning
them to the operating system.