An open table can be changed in memory: values replaced, nulls edited, columns added, dropped or renamed, rows filtered, sorted, ranked or appended. This page is the reference for those operations and the rules they share. The basics of working with a table are in Whole tables in memory: the basics.
%set replaces a column's values from an array of the same length — it changes values, never
the row set:
call t%get("mass", m)
m = m * 2.0_real64
call t%set("mass", m)
By default every row is written and the column's null bitmap is dropped outright — after a plain
%set the column holds no nulls at all. Pass modify_nulls=.false. to leave the null rows, and
their bitmap, exactly as they were.
%set takes two more optional arguments, both shared with the rest of the table API rather than
special to it: is_valid= carries validity alongside the values, shaped like them — per row for a
scalar column, (width, nrows) for a vector one — and is applied after the values, so it survives
the bitmap drop above; and found= reports a missing column instead of aborting. Both are described
once, for every call that takes them, in Reading and writing a column's nulls alongside its
values.
To write a single cell, use %set_element, and to read one, %get_element:
call t%set_element("mass", 42, 1.75_real64) ! row 42 of the mass column
call t%get_element("mass", 42, m) ! ... and back out again
%get_element widens into your variable exactly as %get does, takes either integer kind for
the row index, and is the one-call form of the two-step row handle (r = t%row(42) then
call r%get("mass", m) — see One row at a time).
The value's kind must match the column's exactly, as it does for %set, and writing a value
clears that row's null — a cell cannot be both a value and missing.
%is_null, %set_null and %clear_null each work a row at a time or a single element at
a time, depending on whether you name an element:
call t%set_null("spectrum", 7_int64) ! the whole row: every element of row 7
call t%set_null("spectrum", 7_int64, 3_int64) ! just element 3 of row 7
print *, t%is_null("spectrum", 7_int64, 3_int64) ! .true.
print *, t%is_null("spectrum", 7_int64) ! .true. -- "any element of row 7 is null"
Each call acts at the granularity you named, and the one place that is worth stating twice is the difference between asking and setting:
%is_null(name, i) is .true.
when any element of it is null. So a row you nulled one element of reports itself null, which is
usually what you want to know before using it.Element indices run 1..width and are checked; a scalar column has width 1, so e can only be 1
and the two forms agree. The row handle takes the same pair: r%is_null("spectrum") and
r%is_null("spectrum", 3_int64).
%set_null also takes a mask, in either shape — one logical per row, or one per element,
matching whichever shape %get_valid_mask gave you:
call t%get_valid_mask("flux", valid) ! valid(:) -- per row
valid = valid .and. (flux > 0.0_real64)
call t%set_null("flux", valid) ! nulls every row the mask marks .false.
call t%get_valid_mask("spectrum", vok) ! vok(:,:) -- per element, (width, nrows)
call t%set_null("spectrum", vok) ! nulls individual elements
On a vector column the rank-1 %get_valid_mask is the row summary (.false. where any element
of the row is null) and the rank-2 one is the true per-element state. Both are useful — the first
answers "which rows are complete?" — so both are available, and which you get is decided by how you
declared the array.
It only ever adds nulls: a .true. entry leaves the entry exactly as it was, so a mask describing
only part of what you know cannot clear a null you did not mention.
%has_nulls(name) answers whether a column holds any, as cheaply as its state allows — a column
already read answers from its own validity, and one that has not been read answers from the file's
footer statistics without reading a byte, which is what makes it worth asking before deciding to
read. The footer answer is one-sided: .false. is a guarantee, .true. means "may hold nulls",
because a file written without statistics cannot say. So .true. is a reason to read the column and
look, not a fact to branch on — and reading it may well turn the answer into .false.. Once the
column is resident the answer is exact. %has_nulls also takes a 1-based column position in place
of a name.
A column that holds no null at all carries no null bitmap, which is why %compact_validity(name)
exists: it drops the bitmap of a column that once had nulls and no longer does. A whole-column
%set already does this on its own, so %compact_validity is only for a column edited cell by
cell.
%ensure_validity([name]) is the other side of that laziness. Because the bitmap is allocated on
demand, the first null written to a null-free column allocates — which is fine on one thread and
a race on several, so a shared table refuses it. Calling %ensure_validity first makes the
allocation happen up front and lets the concurrent nulling proceed; with no name it prepares every
resident column. A temporal column never needs it, since its null state lives inside each element
and nulling one allocates nothing. See Thread
safety for where this matters.
Mutation falls into three classes, and the difference between them matters more than any individual procedure:
| class | what it does | detaches? |
|---|---|---|
cell — %set_element, %set_null, %clear_null |
changes values in place | no |
column — %add_column, %drop_column, %rename_column, %copy_column, %cast |
changes which columns exist, or a column's kind | no |
row — %filter_rows, %sort_by, %top_n, %delete_rows, %truncate, %append, %append_null_rows |
changes which rows exist | yes, when it changes one |
call t%materialize("mass,age,zphot") ! read what you want to keep, first
call t%filter_rows(mass > 1.0e10_real64) ! keep the rows a mask selects
call t%sort_by("-mass") ! or sort_by(["mass"], descending=[.true.])
call t%drop_column("scratch")
Two of the five are explained on the neighbouring page rather than here, because both are really
about a column's type rather than about mutating a table: %cast converts a column to another
numeric kind in place, and %copy_column adds a converted (or plain) copy beside the original —
see Changing a column's type. The other three are here.
The two simplest of those are worth a sentence each, because both are cheaper than they look:
%drop_column never reads the column it drops. Dropping one that was never touched is the
memory-reclaiming case and costs nothing; the remaining columns keep their order, so
%column_names still reads like the file. Keeping that order costs nothing either — the
remaining columns' storage is handed over rather than copied.%rename_column changes only the name you look the column up by. A file-backed column that
has not been read yet still reads from the same physical column afterwards, which is what lets a
rename compose with a MAML remap
in either order. The new name must not be blank and must not already be taken.A table opened from a file reads its columns lazily. Once a row-changing operation runs, the
rows in memory no longer line up with the rows in the file, so any column that had not been read
by then can never be read at all. The table records this — %is_detached() reports it — and every
later attempt to read from the file says so rather than returning misaligned data.
So read what you need before changing the row set, with %materialize(names) (or its
synonym %prefetch(names)) — naming the columns, not %materialize_all(), unless you really do
want the whole file. Columns you deliberately do not want are simply left behind, which is what
keeps a lazy table from having to read a whole file before it can drop a single row.
Detaching does not freeze a table: it can still be read, edited and written out, and mutated further. What it loses is the file behind it.
A call that changes no row does not detach. Detaching costs you every column you have not read
yet, permanently, so it is only paid for when the row set actually moved — a row mutation that
turns out to have nothing to do returns without touching a column, without invalidating a %col
pointer, and with the file still attached. Five of the six are decided by the arguments you passed:
| call | changes nothing when |
|---|---|
%truncate(n) |
n >= %nrows() |
%filter_rows(keep) |
every entry of keep is .true. |
%delete_rows(indices) |
indices is empty |
%append(other) |
other has no rows (it is still checked for compatibility first) |
%append_null_rows(n) |
n == 0 |
%sort_by(keys) |
the rows were already in that order |
The last is the odd one out: whether a sort moves anything depends on the data, not on the
call, so %sort_by on an already-ordered column leaves the table attached and the same call after
an edit may not. Treat "did it detach?" as something to ask (%is_detached()) rather than
predict, and never rely on staying attached across a mutation you expect to be a no-op.
%filter_rows(keep) is the general form and takes a mask of exactly %nrows() entries.
%delete_rows(indices) and %truncate(n) are conveniences over it, in either integer kind:
call t%delete_rows([3, 7, 7, 11]) ! a row named twice is still removed once
call t%truncate(1000) ! keep the first 1000 rows
A row index outside 1..nrows is an error, and so is a negative n; both are checked before
anything is changed, so a rejected call leaves the table as it was. %truncate(0) empties the
table. A %truncate asking to keep more rows than there are, a %filter_rows whose mask keeps
everything and a %delete_rows with no indices all return without touching anything and without
detaching — see What "detaching" means.
To keep rows by rank rather than by position or by mask — the best hundred by some column — use
%top_n, which is cheaper than sorting and cutting.
%sort_by takes one or more key columns, primary first, with optional per-key descending= and
nulls_first= arrays:
call t%sort_by(["group", "mass "], descending=[.false., .true.])
Or write the keys as one string, which is usually what you want — commas and/or semicolons
separate them, and a key may carry its own direction in exactly the grammar a read-time
parquet_sortkey key uses (asc/desc, case-insensitive, with a leading - as shorthand for
descending):
call t%sort_by("group,-mass") ! group ascending, mass descending
call t%sort_by("group asc; mass desc") ! the same thing, longhand
The array form needs every key padded to one declared length — note the "mass " above — and
guessing that length too short silently truncates a key name rather than failing. The string
form has neither problem.
Every key-taking binding accepts both spellings: %sort_by, %top_n, %argsort_by,
%argsort_partial and %is_sorted_by. A direction token and a descending= argument say the
same thing twice, so giving both is an error for the whole call — use one or the other.
nulls_first= is unaffected and works with either spelling. A column whose own name contains a
comma, a semicolon, a leading - or a trailing asc/desc is reachable through the array form
only.
A key column that has not been read yet is read for you, by the same lazy first touch %get and
%col use — so sorting a table you have just opened needs no %prefetch first. Only the key
columns are read; the rest stay exactly as they were. Nulls and NaNs are placed absolutely and are
never flipped by descending; ascending order gives values, then NaNs, then nulls. Ties keep their
existing order.
Any scalar column can be a key — numeric, logical, string, date, time or timestamp. A vector
column cannot: there is no defined order on a whole vector row, so naming one is an error, as are
an empty key list and a descending=/nulls_first= array whose length does not match the keys.
Every key is validated before a single column is touched, so a rejected %sort_by leaves the
table exactly as it was — and a sort that finds the rows already in the order asked for moves
nothing and leaves the table attached (see What "detaching" means).
This is the same sort engine parquet_open_reader(..., sort_by=...) uses, so sorting a table in
memory and reading the same file sorted give the identical row order.
When you want the brightest hundred rows and not the other hundred million, %sort_by is the wrong
tool: it orders every row and reallocates every column, to keep 100 of them. %top_n selects
instead. Optional arguments are shown in square brackets; they are not part of the call syntax:
call t%top_n(["flux"], 100, descending=[.true.]) ! the 100 brightest, brightest first
call t%top_n(keys, n, [descending], [nulls_first]) takes the same keys, the same engine and the
same refusals as %sort_by, including reading a key column that has not been read yet. Afterwards
the table holds exactly those n rows, in key order — it is %sort_by followed by %truncate(n),
at O(rows) instead of O(rows log rows), and gathering each column straight to n rows instead of
reindexing it in full and then cutting it down.
"The last n" is descending=, not a separate call — with the default ascending order,
t%top_n(["flux"], 100) keeps the hundred faintest rows.
n is clamped, not checked. Asking for more rows than the table has keeps all of them, so an
n derived from a fraction or a config value needs no min() of its own; a negative n is still
an error. At or above the row count this is a whole sort, and behaves as one — including leaving a
table whose rows are already in that order completely alone.
Like every row-changing operation it detaches the table (see What "detaching" means), so a column you still want must be read before the call, not after:
call t%materialize("name") ! or %materialize_all()
call t%top_n("-flux", 100)
That includes parquet_row_index, which is how to
find out which file rows survived — ask for it before the call and it is gathered along with
everything else.
There is deliberately no %take(indices) taking a permutation you built yourself. Applying an
arbitrary row order to a table is the one thing this type refuses: applied to some columns and not
others, or applied from an array that has since gone stale, it breaks the row correspondence that
makes a table a table, and nothing detects it. %top_n is safe because it builds the indices
itself, from this table's own columns, and applies them to every column together. If you want to
read rows in an order without changing anything, that is %argsort_by and %get_slice, next.
%sort_by detaches the table, which is the right thing when you meant to reorder it — but it
also means there is no way to get a sorted view of a table and keep the file behind it. That is
what %argsort_by is for: it answers with the row order and moves nothing.
integer(int64), allocatable :: perm(:)
real(real64), allocatable :: mass(:)
call t%argsort_by(["mass"], perm) ! the order, unapplied
call t%get_slice("mass", parquet_slice_list(perm), mass) ! read in that order
The table is untouched and still attached, so lazy columns still read, %reload still works, and
nothing has been reallocated. Beyond a sorted read this is also how to apply one table's order to
another table or to a plain array, which nothing else in the library offers.
Same keys, same engine and same refusals as %sort_by, including per-key descending= and
nulls_first=. Optional arguments are shown in square brackets below; they are not part of the
call syntax:
| call | what it gives |
|---|---|
call t%argsort_by(keys, perm, [descending], [nulls_first], [group_offsets], [group_nkeys]) |
the row order the keys imply |
call t%argsort_partial(keys, perm, n, [descending], [nulls_first]) |
just the n best rows, by selection |
call t%top_n(keys, n, [descending], [nulls_first]) |
(mutating) keeps only those n rows — see above |
ok = t%is_sorted_by(keys, [descending], [nulls_first]) |
whether the rows are already in that order |
perm may be declared integer(int32) or integer(int64) on both %argsort_by and
%argsort_partial, and group_offsets follows whichever you chose, since its entries are positions
in perm.
n is the exception to this library's usual two-kind rule, on %top_n and %argsort_partial
alike: it is a plain default-kind integer with no int64 form, deliberately, because an n that
large is not a top-N at all but a whole sort — which is what both delegate to. Row counts and row
indices elsewhere on this page (%truncate, %delete_rows) do take either kind.
%argsort_partial is the one with an asymptotic argument. Asking a 100-million-row table for
its brightest 100 rows through %sort_by sorts everything and reallocates every column;
%argsort_partial selects instead, and reorders nothing at all. n is clamped to the row count
rather than checked, so a derived n needs no min() of its own, and "the last n" is
descending=, not a separate call.
%is_sorted_by costs O(rows) with an early exit, where asking %sort_by the same question
costs a full sort:
if (.not. t%is_sorted_by(["ra"])) call t%sort_by(["ra"])
group_offsets= reports where each run of rows equal under the keys begins, in the same pass as
the sort:
integer(int64), allocatable :: perm(:), go(:)
call t%argsort_by(["field_id"], perm, group_offsets=go)
do g = 1, size(go) - 1
call t%get_slice("mag", parquet_slice_list(perm(go(g):go(g+1) - 1)), mags)
end do
The array has length ngroups + 1 and its last entry is the sentinel nrows + 1, so every group
slices the same way and the last one needs no special case. It is always allocated when asked
for — an empty table gives [1], so the loop above runs zero times rather than touching an
unallocated array. All nulls form one group and all NaNs form one group.
group_nkeys= groups on the first few keys without changing the sort, which is what gives
"grouped by field, ordered by magnitude within each group":
call t%argsort_by(["field_id", "mag "], perm, group_offsets=go, group_nkeys=1)
It counts key names, must be between 1 and the number of keys, and requires group_offsets.
A permutation goes stale silently. It describes the table as it was. Any row-structural change —
%sort_by,%top_n,%filter_rows,%delete_rows,%truncate,%append— invalidates it, and nothing reports that: against a table that has since shrunk, the indices stay in range and name the wrong rows. This is the same hazard as a saved%colpointer, except that a stale pointer usually crashes while a stale permutation just answers wrongly.%generation()is bumped by every such change — record it beside a permutation you intend to keep, and compare before reusing it.
The way to add many rows is a batch that structurally cannot have the wrong columns:
call t%clone_structure(batch) ! same columns, zero rows, no file
call batch%append_null_rows(n) ! give it n rows to fill
call batch%set("id", ids) ! ... and fill them
call batch%set("mass", masses)
call t%append(batch)
%clone_structure reads nothing: every column's kind and width are known from the file's schema,
so a batch can be cloned from a freshly opened table without touching a column. Pass
resident_only=.true. to clone only the columns that have been read.
%append requires the appended table's columns to be a subset of this table's, with matching
kinds, widths and units. A column this table has and the batch does not is null-filled; a
column the batch has and this table does not is an error rather than being silently dropped; a
kind mismatch is an error too, and %cast is the way round it. There is no unit
conversion, so appending "km/h" rows to an "m/s" column is refused.
%append(row) adds one row from a parquet_table_row handle, copying just that row out of the
handle's table. The batch form above is still cheaper per row — a row append takes the table's lock,
advances the generation counter and detaches once per call — but the cost is linear in the rows
added, not quadratic, so building a table a row at a time is a perfectly reasonable thing to do.
Growing a table row by row costs nothing extra in allocation either: a column's storage grows geometrically, so a run of appends reallocates a handful of times rather than once per row. The price is that an appended-to table can hold up to 1.5x the storage its rows need. Two calls control that, and neither changes the row set, so neither detaches:
call t%reserve(1000000) ! one allocation up front, if the final size is known
do i = 1, 1000000
call t%append(rows%row(i))
end do
call t%compact() ! give the slack back
Both invalidate every %col pointer and row handle into the table, because both reallocate
storage without changing the row set — as do %cast, %evict_column and %reload, which replace
or release one column's storage while the rows stay as they were. %generation() advances only
when something actually moved, so the usual take-it-before / compare-it-after / re-fetch pattern
re-fetches only when a pointer really did die.
%compact() is a no-op on a table that has not been appended to: reading a column from a file
allocates exactly what it holds, and so does every rebuild (%filter_rows, %sort_by, %top_n,
%delete_rows, %truncate), which hand their memory back on their own. So it is safe to call
unconditionally. It is not needed before parquet_write_table — the writer never sees the slack.
%reserve(n) takes the total row count to make room for, not an increment, and reserving less than
the table already holds does nothing.
Appending a zero-row table is checked for compatibility exactly as any other append, and then
does nothing at all — including not detaching. %append_null_rows(0) is the same.
%reserve_columns(n) is the column-side counterpart of %reserve(n), and unlike everything else
on this page it exists for safety rather than speed:
While spare column capacity remains, adding a column under a NEW name relocates no existing column's storage, moves no existing column's slot position, and does not advance
%generation(). A pointer taken from%col, aparquet_table_colhandle and aparquet_table_rowhandle all stay valid across such a call.
That is what makes the commonest derived-column idiom safe rather than merely lucky:
call t%reserve_columns(t%ncols() + 2) ! two derived columns coming
call t%col("mag_g", g) ! pointers taken up front...
call t%col("mag_r", r)
call t%add_column("g_minus_r", g - r) ! ...and still valid here, by contract
call t%add_column("is_blue", g - r < 0.5_real64)
Without the reservation, an %add_column that happens to fill the slot array reallocates it, and
Fortran leaves a pointer's association status undefined across the MOVE_ALLOC that does it —
code that usually works and is not permitted to.
Three things the guarantee deliberately does not cover:
force=.true. — replacing an existing column frees that column's storage, which no amount
of spare capacity prevents. The guarantee is about adding.%reserve_columns is a relocation when it actually grows,
so it invalidates everything and advances %generation(), exactly as %reserve (rows) does.
Reserve first, take pointers second.n is the total capacity to make room for, not an increment, so reserving less than is already
allocated does nothing. %column_capacity() reports how many slots exist and
%column_capacity(free=.true.) how many are spare — capacity only ever grows, and a reservation
survives %clone and %clone_structure.
Mutation happens in place and there is no undo, so the way to keep a version to return to is to copy first:
call t%clone(before) ! independent deep copy
call t%filter_rows(keep)
A clone copies the columns already read and leaves the unread ones unread, opening its own reader
on the same file so it stays lazy — with the same read-time transform reattached, so a column the
source never touched still comes back with the source's rows. Cloning a detached table copies
the values it holds and leaves the copy detached too: there is no file left to reopen, for either
of them. before must be declared as the same concrete table type as t.
A pointer from
%coldoes not survive a row-changing operation.%filter_rows,%sort_by,%appendand the rest reallocate each column's storage, so a pointer taken before one of them points at freed memory afterwards. Fortran cannot detect this. Take the pointer again after the mutation.
There is no table-level binary search, and it is not missing: %col hands back a plain pointer to a
column's storage, and parquet_sorting's searches work on that
directly.
real(real64), pointer :: ra(:)
integer(int64) :: lo, hi
logical :: ok
call t%sort_by(["ra"])
call t%col("ra", ra)
call pf_is_sorted(ra, ok) ! O(n), once
do i = 1, size(targets)
call pf_equal_range(ra, targets(i), lo, hi, assume_sorted=ok)
! rows lo .. hi-1 hold targets(i)
end do
Check once, outside the loop. assume_sorted defaults to .false., which is the safe default —
searching unsorted input returns a plausible index with no symptom — but it makes each search O(n)
in front of an O(log n) operation. Hoisting one pf_is_sorted out of the loop turns m searches
from O(m·n) into O(n + m log n).
Re-take the pointer after any row-changing operation, per the warning above, and re-check sortedness after anything that could disturb the order. A table does not remember that it is sorted, deliberately: a stale "still sorted" flag would return wrong rows silently.
Likewise there is no %rank — adding a rank column is four calls through %col, shown here as a
complete program since it is the one place on this page where getting the nulls right matters:
program rank_by_flux
use parquet
use iso_fortran_env, only: int64, real64
implicit none
type(parquet_table) :: t
real(real64) :: flux(5)
real(real64), pointer :: fp(:)
integer(int64), allocatable :: ranks(:)
logical, allocatable :: valid(:)
integer :: i
flux = [3.0_real64, 1.0_real64, 4.0_real64, 1.0_real64, 5.0_real64]
call parquet_new_table(t)
call t%add_column("flux", flux)
call t%set_null("flux", 2_int64) ! two rows with no measurement
call t%set_null("flux", 4_int64)
call t%col("flux", fp) ! the values...
call t%get_valid_mask("flux", valid) ! ...and which of them are real
call pf_rank(fp, ranks, descending=.true., is_valid=valid) ! 1 = brightest
call t%add_column("flux_rank", ranks)
do i = 1, int(t%nrows())
print '(a,i0,a,i0)', "row ", i, " rank ", ranks(i)
end do
end program rank_by_flux
which prints ranks 3 0 2 0 1 — the two nulled rows ranked 0, and the three real values ranked
brightest-first.
method= picks how ties are handled — "competition" (1,2,2,4, the default), "dense" (1,2,2,3)
or "ordinal" (1,2,3,4) — and a null gets rank 0, since a missing value has no rank rather than
the last one. Dense ranks over a sorted key column are also the cheapest way to label groups: every
row of a group gets the same number.
is_valid= is what makes that rank-0 rule reachable, and leaving it out is a silent wrong
answer. %col hands back the values array and nothing else — a parquet_column keeps its nulls
in a separate bitmap — so a pf_rank given only the pointer has no idea which rows are missing and
ranks them as ordinary values. Worse, a null row's value bytes are unspecified, so what it ranks
them by is undefined. On a five-row column with rows 2 and 4 nulled, the call without the mask
gives 3 4 2 4 1 where the call with it gives 3 0 2 0 1 — plausible ranks either way, and only
one of them right. Pass the mask, or be sure the column holds no nulls.
The same applies to every pf_* operation reached through a %col pointer, including the searches
above: each takes its own optional is_valid=, and without
it a null row participates as whatever its value slot happens to hold.