The following intrinsic Fortran kinds (from iso_fortran_env) are supported throughout the write,
read and metadata APIs, both as scalars/1D arrays and as 2D vector columns:
| Fortran kind | MAML data_type |
|---|---|
integer(int32) |
int32 |
integer(int64) |
int64 |
real(real32) |
float32 |
real(real64) |
float64 |
logical |
boolean |
character(len=*) |
string |
Vector column entries use the shape convention (col_size, nrows) for arrays passed to
parquet_write_column or produced by parquet_read_column.
Three additional element-level types — parquet_date, parquet_time, parquet_timestamp (module
parquet_temporal, re-exported from use parquet) — read and write Parquet DATE/TIME/
TIMESTAMP columns, one value per element (type(parquet_timestamp) :: ts(nrows) for a scalar
column, ts(col_size, nrows) for a vector column), through the same parquet_write_column/
parquet_read_column/chunked/row-mode/element-mode calls as every type above. Unlike every type in
the table above, these carry their own null state (no is_valid=/null_value= argument, and a
null-containing column reads without the error-on-Null the numeric/string readers apply by default)
and their write-time unit/timezone is declared via a MAML token
(timestamp[ns,utc]/time[ms]/date) rather than inferred. See Date, time and timestamp
columns for the full guide — types, null semantics, units, Unix-time/MJD/JD
interop, and reading files from other tools (legacy INT96, arbitrary timezones). qc: bounds are
not yet supported for these three types; parquet_filter rules are, against a double-quoted
ISO-8601 literal — see Filtering date, time and timestamp
columns.
A string (scalar or vector-of-strings) column's underlying Arrow representation is chosen
automatically based on size. Normally it's Arrow's default utf8 type, which caps a single column's
total string byte payload at 2^31-1 bytes (~2 GiB) — but if writing a column would exceed that, this
library transparently switches that column to large_utf8 (64-bit offsets, no such limit) instead.
This is fully automatic and requires no action from either the writer or reader side:
parquet_write_column/parquet_read_column and every other read function behave identically either
way, including row filtering (parquet_filter) and qc: range checks. The only place the
difference is visible is parquet_close_reader(print_stat=.true.)'s parquet_type column, which
shows large_string/list<large_string> instead of string/list<string> for a column that was
promoted.
Two write paths skip the size test and always use large_utf8, however small the column is. A
streamed column (parquet_write_column_chunk) has its Arrow field fixed the moment the first
row group locks the file's schema — long before the whole column's byte payload has been seen — so
it takes the 64-bit form unconditionally rather than risk a later row group overflowing an
already-fixed utf8. A column written from a parquet_string_column takes it because that
container's own storage already carries 64-bit offsets, so there is no int32-offset variant to
choose between. Both therefore report large_string in parquet_type; nothing else about them
differs.
string_view columns from other toolsThis library's own writer never produces Arrow's string_view representation — it only ever appears
when reading a Parquet file written by another Arrow-based tool whose stored Arrow schema declared a
column as utf8_view(). Reading such a column works transparently through
parquet_read_column/parquet_get_string_length and every other scalar/vector string read
function, exactly like string/large_string above, including qc: range checks and row filtering
(parquet_filter). parquet_close_reader(print_stat=.true.)'s parquet_type column shows
string_view for such a column, and goes on showing it whatever else is in force — that cell
reports the file's own stored schema, which nothing on the read side rewrites. Filtering does cast
the column to large_utf8 internally first, because Arrow's row-filter compute kernel has no
string_view support to call directly, but the cast is invisible: it changes no read result and no
diagnostic.
The compact parquet_string_column (see String columns) read path applies
that same cast for its own reason. Its buffer handoff needs the offsets-plus-payload layout that
only string/large_string have — a view array's values are inlined or spread across several data
buffers — so a string_view column is converted to large_utf8 first and then handed over. It
costs one pass over the column's bytes, and the result replaces the cached column, so reading it a
second time pays nothing. Values, nulls and reported types are unchanged either way.
col_size) limitUnlike a scalar column's row count, which this library supports beyond Fortran's default-integer
huge(1) (2,147,483,647) for every data type, a single row's own vector width (col_size) is
capped at that same number. This is a hard limit of Arrow's FixedSizeListType itself — its
list_size is a plain int32_t, and unlike Arrow's string type (see Large string
columns above), there is no "large" fixed-size-list variant to fall back to.
Writing a vector column whose col_size would exceed this aborts the process (a C++-level abort
with a diagnostic on stderr, the same class of failure as the physical-type-mismatch case in
Limitations) rather than silently truncating col_size and
corrupting the written column.
Parquet's own repetition/definition-level generation for list-typed columns walks every flattened
element of a single row group with a plain int32_t counter, so a row group's own
row_group_rows * col_size is capped at 2,147,483,647 — but this is scoped to one row group, not
the whole file. parquet_close_writer's row-group auto-sizing (see Writer
options) already knows each column's col_size and silently
picks a smaller row-group size whenever a wide vector column needs it, so a column's total
nrows * col_size can exceed 2,147,483,647 — a real, hittable case (e.g. 2.5 billion rows at
col_size=2) — without any special handling: it is transparently split across multiple row groups
and round-trips normally. The only case that still aborts is an explicitly chosen chunk_size
(parquet_open_writer(..., chunk_size=)) that conflicts with a vector column's col_size —
silently shrinking a caller's explicit request would be a surprising, hard-to-notice performance
change, so this aborts the process instead (a C++-level abort with a diagnostic on stderr, the same
class of failure as the col_size case above) rather than either silently overriding the request or
letting Arrow itself throw an uncaught IOError: List index overflow mid-write.
A file is capped at 2,147,483,647 columns — the third and last of Arrow's hard int32 ceilings,
alongside the two above. arrow::Schema::num_fields() and GetFieldIndex() return a plain
int32_t, and unlike a column's row count there is no 64-bit or "large" variant to fall back to.
Writing a column that would push the file's column count past this aborts the process (a C++-level
abort with a diagnostic on stderr, the same class of failure as the two cases above) rather than
letting Arrow's own field bookkeeping wrap silently. Reaching it takes an enormous amount of memory
and time first — every column carries its own name, type and metadata — so unlike the col_size
ceiling it is not a limit you meet by accident.
parquet_read_column (and parquet_read_array_row_mode/parquet_read_array_element_mode) dispatch
on the declared type/kind of the values array you pass in, not the column's own stored type — so
values' kind doesn't have to match the file's data_type exactly, as long as the conversion is
one of the following:
Requested values kind |
Convertible from stored data_type |
|---|---|
integer(int32) |
int32, int64 (checked for overflow — see below); int8, int16, uint8, uint16 (always exact); uint32, uint64 (checked for overflow); float32, float64, half_float, and decimal (checked for a fractional part and for overflow — see below) |
integer(int64) |
int64, int32, int8, int16, uint8, uint16, uint32 (always exact); uint64 (checked for overflow); float32, float64, half_float, and decimal (checked for a fractional part and for overflow — see below) |
real(real32) |
float32, float64, int32, int64, int8, int16, uint8, uint16, uint32, uint64, half_float, decimal |
real(real64) |
float64, float32, int32, int64, int8, int16, uint8, uint16, uint32, uint64, half_float, decimal |
logical |
boolean only |
character(len=*) |
string only — but in any physical form: utf8, large_utf8 and utf8_view alike, per the two sections above |
int8/int16/uint8/uint16/uint32/uint64/half_float/decimal here are physical
Parquet/Arrow storage types this library's own writer never produces (writing stays limited to the
six types in the table at the top of this page, plus date/time/timestamp) — they only ever
arise from a file written by some other tool. There is nothing to declare for them in a MAML schema
or anywhere else: the conversion is purely internal to the read path, triggered automatically by
whatever physical type the column already has on disk.
To find out which physical type a file's column actually holds — and so which kind to declare —
ask parquet_get_column_type, which reports the narrowest lossless Fortran kind for it; see
What a column is read as.
Anything not listed for a given values kind (e.g. requesting logical/string from anything
else) fails immediately, naming the column and the type mismatch. Every failure described in this
section is a C++-level abort with a diagnostic printed to stderr, not a Fortran error stop — the
same class of failure as the physical-type-mismatch case in
Limitations.
Conversions that lose information silently, with no warning:
int64 → integer(int32), and uint32/uint64 → integer(int32)/integer(int64) where the
value doesn't fit, are checked for overflow: a stored value outside the target's range aborts
(e.g. "...int64->int32 overflow...", "...uint64->int64 overflow...") rather than wrapping.int32/int64/int8/int16/uint8/uint16/uint32/uint64 → real32/real64, float64 →
real32, and half_float/decimal → real32/real64, are not checked for precision loss —
a large int64 (beyond ~2^53) or int32 (beyond ~2^24) read into real32 silently loses
exact-integer precision, the same way a plain Fortran real(int_value, kind=real32) conversion
would.A float32/float64/half_float/decimal value read into an integer(int32)/integer(int64)
array is never silently truncated, unlike the narrowing above: a value with a nonzero fractional
part aborts immediately (e.g.
"...double value has a fractional part, cannot convert to int32 for column: ..."), and an
otherwise-integral value that doesn't fit the target width aborts with the same kind of overflow
message as the integer conversions above (e.g. "...double->int64 overflow for column: ...").
This exists specifically to support reading a column that is intrinsically an integer quantity but
happens to have been written as a floating-point or decimal type by whatever tool produced the file
— not to allow lossy float-to-int rounding.
qc: min:/max: range checks and row filtering (parquet_filter) cover every type in the table
above the same way they already cover int32/int64/float32/float64 — both compare against the
column's raw physical value regardless of its stored type, so no MAML/filter-side change is needed
to use them against one of these extended types.
The write side converts in the opposite direction, and the asymmetry is worth holding onto. A
read dispatches on the array you declare and converts the file's values into it; a write against a
schema converts the values you pass into the data_type the schema declares (int32, int64,
float32 and float64 in any combination, on both parquet_write_column and
parquet_write_column_chunk), so the file holds the declared type rather than the kind of the array
you handed over. See Streaming/chunked
writes for the rules, including which conversions fail
rather than round.
date/time/timestampcolumns are the exception to this whole section:parquet_date/parquet_time/parquet_timestampcarry their own null state per element, so there is nonull_value=/is_valid=argument for them anywhere, and a null-containing column reads without the error-on-Null described below — see Null values are part of the element.
Fortran has no per-element representation for a missing/Null value. On the read side, if a
column contains any genuine Parquet Null (e.g. a file produced by another tool), the default
behavior of parquet_read_column, parquet_read_array_row_mode, and
parquet_read_array_element_mode is to abort the process immediately, rather than silently
returning undefined data. This is a C++-level abort with a diagnostic printed to stderr (e.g.
parquet-fortran: parquet_read_int32_column: column contains Null value(s), which is not supported: <column>),
not a Fortran error stop — the same class of failure as the physical-type-mismatch case in
Limitations.
To read a Null-containing column instead of erroring, pass one or both of these optional keyword
arguments (supported by all three of the read families above, for every data type in the table at
the top of this page — the two element types that carry their own null state, the temporal types
above and parquet_string_column below, take neither):
null_value=nullval — a scalar of the same type as values; every Null in the column is replaced
with nullval in the returned values.is_valid=mask — a logical array of the same shape as values; .false. wherever the Parquet
value was Null, .true. otherwise.If only is_valid is given (no null_value), Null slots in values are still filled with a safe
type-appropriate default (0 / .false. / blank string) rather than left as undefined data — check
is_valid to know which entries are real. If both are given, Nulls are replaced with nullval
and is_valid still reports which entries were originally Null. Omitting both keeps the default
strict (error-on-Null) behavior.
A complete round trip through all three of those, writing two genuine Nulls and reading them back twice:
program null_values_example
use parquet
use iso_fortran_env, only: real64
implicit none
type(parquet_writer) :: writer
type(parquet_reader) :: reader
real(real64) :: flux(5), got(5), filled(5)
logical :: written(5), present(5)
flux = [1.5_real64, 2.5_real64, 3.5_real64, 4.5_real64, 5.5_real64]
written = [.true., .true., .false., .true., .false.]
! Rows 3 and 5 are written as genuine Parquet Nulls; flux(3)/flux(5) are ignored.
call parquet_open_writer(writer, "flux.parquet")
call parquet_write_column(writer, "flux", flux, is_valid=written)
call parquet_close_writer(writer)
call parquet_open_reader(reader, "flux.parquet")
! is_valid= alone: the Null slots come back as 0.0, and `present` says which those are.
call parquet_read_column(reader, "flux", got, is_valid=present)
! null_value= instead: the same slots come back as -99.0, with no mask to consult.
call parquet_read_column(reader, "flux", filled, null_value=-99.0_real64)
call parquet_close_reader(reader)
print *, present ! T T F T F
print *, got ! 1.5 2.5 0.0 4.5 0.0
print *, filled ! 1.5 2.5 -99.0 4.5 -99.0
end program null_values_example
Reading flux with neither argument would abort instead, because the column really does contain
Nulls — that is the strict default the two arguments opt out of.
For vector columns, a slot is reported/treated as Null if either the whole row is missing or that
specific element within the row is missing (Parquet's list columns track these independently);
is_valid(j, i) reflects the combination of both.
A scalar string column read into a parquet_string_column takes neither
argument either, for the same reason the temporal types don't: that container carries a null state
per element of its own, so each Null in the file lands as %append_null() and %is_null(i) is what
you check afterwards. There is nothing for a mask to report or a sentinel to fill.
parquet_get_string_length is unaffected by any of this: it always silently skips Nulls when
computing the maximum string length, since sizing an output buffer shouldn't depend on how you plan
to handle Nulls when reading.
On the write side, parquet_write_column(writer, name, values, is_valid=mask) accepts the same
kind of logical mask (same shape as values, .false. = write a genuine Null there); there is no
null_value on the write side, since a value used to detect a Null (rather than substitute one,
as on read) would risk misclassifying a legitimate value that happens to equal the sentinel.
Whatever is in values at a .false. slot is ignored — a real Parquet Null is written there
regardless. For vector columns, is_valid is element-level only: an entire row's vector can never
be Null, only individual elements within it. A column only becomes nullable in the file's schema if
is_valid is actually passed and contains at least one .false. entry; omitting is_valid (or
passing an all-.true. mask) writes the column exactly as it would with no mask at all, keeping it
non-nullable.
A streamed column's nullability comes from its FIRST row group, and from whether you passed a
mask — not from what the mask said. parquet_write_column_chunk fixes the column's Arrow field the
moment the first row group locks the file's schema, long before any later row group exists. So the
question it can answer is "did the caller pass an is_valid mask?", and the column is nullable
exactly when the answer is yes — an all-.true. mask still makes it nullable, because you have said
that Nulls are possible for this column even if this chunk holds none.
Every later row group must then use the same form, and mixing them is a hard error rather than a silently ignored mask:
call parquet_new_row_group(writer, 3)
call parquet_write_column_chunk(writer, "flux", a, is_valid=mask) ! nullable from here on
call parquet_finish_row_group(writer)
call parquet_new_row_group(writer, 3)
call parquet_write_column_chunk(writer, "flux", b) ! aborts: the mask was dropped
In practice this costs nothing, because a chunked write is normally one loop body: the mask is
either there on every call or on none. If you cannot know in advance whether a Null will turn up,
pass an all-.true. mask in the first row group and the column stays nullable for the rest of the
file.
Two exceptions, for the same reason in both: date/time/timestamp columns and a
parquet_string_column carry their null state inside the element, with no mask to pass, so a
null-free first row group would say nothing about the seventh. Both are written nullable when
streamed — unless the column is protected, which is how you declare one of them null-free (see
Null values below).
This differs from a whole-column parquet_write_column, which sees every value before it writes
anything and so decides from the values themselves: a column with no Null in it is written
non-nullable whether or not a mask was passed. call parquet_get_column_nullable(reader, name,
is_nullable) reports what a file actually ended up with, for either path.
To forbid Nulls in specific columns even when a caller passes is_valid, list them under a MAML
schema's extra: section as protected_cols:, either as a semicolon-separated scalar or a
dash-list:
extra:
protected_cols: col1;col2;col3
or equivalently:
extra:
protected_cols:
- col1
- col2
- col3
Every name listed must be one of this same MAML file's own declared fields: (checked by
parquet_validate_maml; unknown names fail validation), and it is matched by the name written to
the file — so where a col_map: entry renames a field, protected_cols: must carry the output
name, not the internal one. If a user MAML overrides a base MAML, protected_cols: is taken from
whichever MAML is actually used to build the writer's schema (the user MAML if one is provided,
otherwise the base MAML) — not merged across both.
A protected column may hold no Null at all, whatever kind it is and however the Null was
expressed. Writing an is_valid mask with any .false. entry for one fails immediately with
error stop — and so does writing a date/time/timestamp column containing a null element, or
a parquet_string_column containing an %append_null(), even though neither of those takes an
is_valid argument at all. The rule is about the values reaching the file, not about which argument
carried them. This only applies when writing against a MAML-derived schema
(parquet_open_writer(..., schema, ...)); a schema-less writer has no protected_cols: to enforce.
A protected column is written non-nullable, on every path — which is also the only way to
declare a streamed date/time/timestamp or parquet_string_column column null-free, since
those carry their nulls in the element and are otherwise nullable whenever they are streamed.
Passing an all-.true. is_valid mask for a protected column is fine and changes nothing: it is
checked and then discarded, so declaring a column protected never makes the is_valid keyword
unusable. Because the mask is discarded before the same-form rule above is applied, a protected
column is also exempt from that rule — a mask cannot say anything its protection has not already
settled. Keep the form uniform anyway; nothing enforces it there.
You can protect a column from code as well as from a MAML, with
call schema%set_protected(name [, protected]) — protected defaults to .true., and .false.
lifts protection. Call it before parquet_open_writer, since the writer takes its own copy of the
schema at open time. Unprotecting a column the MAML itself declared protected is allowed but prints
a warning naming it: someone wrote that declaration down deliberately, and overriding it in code
should be visible in the program's output. See
Building a schema in code.
A Parquet STRUCT column's individual fields — at any nesting depth — can be read directly by
passing a dot-separated path as the name argument to parquet_read_column,
parquet_read_array_row_mode/parquet_read_array_element_mode, parquet_read_column_chunk,
parquet_get_col_size/parquet_get_column_total_elements, qc: bounds in a MAML schema, and
parquet_filter rules — every one of these dispatches on name the same way, so a struct-nested
column is used identically to a top-level one everywhere. Given a file with
main : STRUCT
├── id : int32
└── inner : STRUCT
├── name : string
└── age : int32
parquet_read_column(reader, "main.id", ...) and
parquet_read_column(reader, "main.inner.age", ...) read those two leaves directly; nesting depth
is unlimited ("main.inner.deeper.value" works the same way). An exact top-level field name always
wins over path-splitting first, so an existing column literally named with a . in it is
unaffected. A dotted path may also resolve to a vector (FIXED_SIZE_LIST) leaf nested inside a
struct — reading it behaves exactly like any other vector column (col_size, row/element modes,
(col_size, nrows) shape, all apply unchanged).
A path must resolve all the way down to a leaf column: naming an intermediate struct directly
("main.inner", or just "main" when main is itself a struct) is not readable by any type this
library supports and fails the same way as any other unknown column
(error stop "...: column not found in parquet file: ...") — there is no struct/record output type
to read it into. MAP columns, and variable-length LIST columns, are not supported anywhere along
a struct path — neither as an intermediate hop nor as the terminal leaf — and are rejected with the
same "column not found" class of error rather than a silent wrong answer or a crash.
Null handling combines every level a path passes through: a leaf is reported/treated as Null
(via null_value=/is_valid=, above) if the top-level struct itself is missing for
that row, or any intermediate struct field is missing, or the leaf itself is missing —
generalizing the same "missing if either the row or the specific slot is missing" principle vector
columns already use, from one level of list-nesting to arbitrary levels of struct-nesting.
qc: and parquet_filter both work against a dotted path exactly as they do against a
top-level column — a MAML field's name: can be a dotted path, and a parquet_filter%add rule's
column can be one too.
parquet_close_reader(..., print_stat=.true.)
(above)
shows one row per top-level physical struct column touched by any of its leaves being read, not
one row per leaf — its nulls/min/max reflect the whole struct's own top-level figures (which
degrade to blank/- for a struct, since those statistics aren't well-defined for a nested type),
and output_type shows whichever leaf under that struct was most recently read. This is a known,
accepted limitation, not a bug: reading two different leaves under one struct is still reported as a
single touched column.
This library's own writer cannot produce STRUCT columns — like the extended read-only source types
in Reading a column into a different numeric kind
above, struct support is read-only, for files produced by some other tool.