Schema
BackSchema defines the structure and validation rules for DataFrame data. It provides type safety, data validation, and metadata management for your data processing pipelines.
Understanding Schema Components
A schema consists of column definitions that specify:
- Name: The column identifier
- Type: The expected data type (class string)
- Nullable: Whether NULL values are permitted
- Metadata: Key-value pairs for additional context
Structure field order is part of a structure type's identity: structure{a, b} and structure{b, a}
are different types, so Schema::isSame() and Definition::isSame() distinguish them. Value-level
comparisons (Row::isEqual(), Rows::unique()) stay field-order-insensitive.
Arrays in a Schema
type_array() declares a json column. The data layer has no array type - parquet, Spark and Floe all express an
array as a JSON object or a JSON collection - so a declared array<mixed> is projected onto json, and the value is
stored as a Flow\Types\Value\Json, which preserves whether it was an object or a collection.
definition_from_type('tags', type_array())->type()->toString(); // "json"
The projection applies at every depth, not only to whole columns. A structure field, list element or map value declared
as array<mixed> is rewritten to json when the definition is built:
structure_schema('user', type_structure(['tags' => type_array()]))->type()->toString(); // "structure{tags: json}"
list_schema('batches', type_list(type_array()))->type()->toString(); // "list<json>"
map_schema('translations', type_map(type_string(), type_array()))->type()->toString(); // "map<string, json>"
The empty array array{} is not a column type - it is a value whose element type was never observed. Declaring one
is refused, because a column typed array{} could never hold anything:
definition_from_type('tags', type_empty_array());
// RuntimeException: Column "tags" cannot be typed as array{} - an empty array is a value, not a
// column type. Declare the element type, e.g. type_list(type_string()).
Declare the concrete shape whenever it is known - type_list(), type_map() or type_structure() keep element typing
that json throws away, and adapters can map them onto native nested types.
Schema Inference
Sources fall into two groups when no schema is declared.
Sources that infer one. CSV, Excel, JSON and JSON lines sample the file - and Google Sheet the sheet - before the
first row is read, decide one schema, and every batch they yield carries exactly that schema -
from_csv(...)->schema(), from_excel(...)->schema(), from_json(...)->schema(), from_json_lines(...)->schema()
and from_google_sheet(...)->schema() answer without running the pipeline. Tune the sample with
->inferSchema(infer_schema()->sampleSize(...)->filesToSniff(...)->types(...)->allStrings()->unionByName()), where
filesToSniff() counts files that yielded a row (DuckDB's files_to_sniff counts files opened; here a glob of empty
files is opened in full) and so does nothing for Google Sheet, which reads a single sheet. types() restricts
which types inference may produce (allStrings() is sugar for types(type_string())), and unionByName() reads
sources with differing column sets as one wider schema, instead of rejecting them (CSV, Excel and Google Sheet,
which check the column set against the header) or dropping the columns a later source introduces (JSON, which has no
header to check). Every inferred column is nullable, and where narrowing is not safe the column floors to string.
from_array(), from_memory() and the from_sequence_*() extractors infer the same way, from their first
sampleSize rows (20,480 by default). from_array() given a non-array iterable types every row. A row after the
sample that does not fit the inferred type fails the read with an InferredSchemaException naming that row - infer
from every row with ->inferSchema(infer_schema()->sampleSize(-1)), or declare the schema.
Sources that do not. For the rest, every value still gets its type detected as rows are created and each batch carries its own schema.
DataFrame::schema() describes the plan, not the data. It walks the pipeline's steps once, threading each
step's output schema into the next, starting from the source's own schema() - so it may do the I/O that source
needs to describe itself (a CSV sniff, a Parquet footer), but it never reads a row. A column a step cannot name
before rows flow is a build error, raised there rather than mid-run: select('nope') and
ref('a')->greaterThan(ref('b'))
over incomparable types both refuse at the first trigger. printSchema() formats that same answer and runs
nothing. A plan containing joinEach() cannot be described this way and refuses.
<?php
use function Flow\ETL\DSL\{data_frame, from_array};
data_frame()
->read(from_array([
['id' => 1, 'tags' => ['new', 'sale']],
['id' => 2, 'tags' => []],
]))
->schema();
// id: ?integer
// tags: ?list<?string>
Inference sees only the values in front of it:
[]detects aslist<null>- a collection whose element type was never observed. It is the bottom of the type lattice, so it unifies with any other list rather than destroying its element type. In the example the first row inferstags: list<string>, and merging it with the second row's empty array yieldslist<?string>: the element is still a string, but it is no longer known to be present in every row.- An array whose element types do not unify detects as
array<mixed>-jsonin the schema, because no narrower type fits.[1, 2, 'a']and[1, true]are such arrays;[1, 1.5]is not, because integer and float unify tofloat, givinglist<float>. - Sources that transport everything as text (CSV, JSON) read each cell as a string and narrow it to the
richest type that parses, so a CSV column of
10.5infers as?float, not?string.
Inference is a fallback. When the source schema is known, declare it on the extractor ("Declaring the Source Schema" below); when it is not, the extractor infers one - including from a plain PHP array ("Inferring Types from an Array" below).
When Two Types Disagree
A column holds exactly one type. When two rows disagree, the schema merge widens them to the narrowest type that can hold both, and the result is fixed:
| both sides are | result |
|---|---|
containers (json, list, map, structure) |
json |
| anything else | string |
data_frame()
->read(from_array([['a' => 1], ['a' => true]]))
->schema();
// a: ?string
A union is not a column type. null|T is the single exception, and it does not mean "either" - it means a nullable
column of T. Any other union is refused when the definition is built:
definition_from_type('value', type_union(type_string(), type_null()));
// StringDefinition, nullable
definition_from_type('value', type_union(type_string(), type_integer()));
// UnsupportedUnionTypeException: Column "value" cannot be typed as "integer|string": a column holds
// exactly one type. Only "null|T" is a valid union - that is a nullable column.
// Possible fixes:
// * Declare the widest common type: str_schema('value')
// * Declare json_schema('value') when the shape is genuinely dynamic
Declaring the Source Schema
Most extractors accept a schema up front: fluent withSchema(Schema), or the optional $schema argument of the
from_* DSL function. Declared types drive row creation instead of inference, and the row keeps only the columns the
schema mentions.
<?php
use function Flow\ETL\Adapter\CSV\from_csv;
use function Flow\ETL\DSL\{bool_schema, data_frame, float_schema, int_schema, schema, str_schema};
data_frame()
->read(from_csv(__DIR__ . '/orders.csv')->withSchema(schema(
int_schema('id'),
str_schema('customer'),
float_schema('total'),
bool_schema('paid'),
)))
->printSchema();
// schema
// |-- id: integer
// |-- customer: string
// |-- total: float
// |-- paid: boolean
The same works for in-memory data - declaring tags as a list keeps the element type the inference example above
threw away:
<?php
use function Flow\ETL\DSL\{data_frame, from_array, int_schema, list_schema, schema};
use function Flow\Types\DSL\{type_list, type_string};
data_frame()
->read(from_array([
['id' => 1, 'tags' => ['new', 'sale']],
['id' => 2, 'tags' => []],
])->withSchema(schema(
int_schema('id'),
list_schema('tags', type_list(type_string())),
)))
->printSchema();
// schema
// |-- id: integer
// |-- tags: list<string>
The HTTP extractors accept a schema the same way - see
Typing the row with a schema for declaring the response body as a
structure.
Self-descriptive formats carry their schema in the file itself, so their extractors read it from there and have no
withSchema() - Parquet and Floe in this repository.
PostgreSQL sources describe themselves, and keep withSchema()
The from_pgsql_cursor(), from_pgsql_limit_offset() and from_pgsql_key_set() extractors derive their schema from
the database. Unlike a file format, a driver's type mapping is lossy, so these sources keep withSchema() as an
override that always wins:
<?php
use function Flow\ETL\Adapter\PostgreSql\from_pgsql_cursor;
use function Flow\ETL\DSL\{data_frame, float_schema, int_schema, schema, to_output};
// Derived: one zero-row probe of your own query, memoised for the whole read.
data_frame()
->read(from_pgsql_cursor($client, 'SELECT id, amount FROM orders ORDER BY id'))
->write(to_output())
->run();
// Declared: withSchema() short-circuits the probe entirely and costs no query.
data_frame()
->read(
from_pgsql_cursor($client, 'SELECT id, amount FROM orders ORDER BY id')
->withSchema(schema(int_schema('id'), float_schema('amount'))),
)
->write(to_output())
->run();
How the derivation behaves:
- It executes a zero-row probe of your query (
SELECT * FROM (<your sql>) flow_describe LIMIT 0) and reads the result's column metadata. It describes the result, never the catalog, so a view, an alias and a computed column all describe correctly. - A parameterised query describes normally. The probe binds
nullat every parameter position, so it never sees the values you passed. - Every derived column is nullable. Result metadata cannot prove that a column is
NOT NULL- an outer join or an expression column can always producenull. - The schema is derived once per read, before the first row, and every batch conforms to it.
A query that cannot be read or described does not read at all:
| What is wrong | What you get |
|---|---|
| the SQL does not parse | ParserException, before any query runs |
not exactly one read-only SELECT or VALUES (INSERT ... RETURNING, two statements, a data-modifying WITH, SELECT ... INTO) |
InvalidArgumentException, before any query runs |
| a table, column, function, type or privilege the query names is missing | PostgreSQL's own QueryException, positioned in your SQL - what the read throws |
| any other refusal of the zero-row probe | SchemaNotDerivableException, getPrevious() is PostgreSQL's error |
a column type Flow has no type for (record, point, line, lseg, box, path, polygon, circle) |
SchemaNotDerivableException |
->withSchema(...) skips the probe; it helps the last row, and the one before it when the query runs as written.
Doctrine DBAL sources describe themselves, and keep withSchema()
from_dbal_query(), from_dbal_queries(), from_dbal_limit_offset(), from_dbal_limit_offset_qb() and
from_dbal_key_set_qb() derive their schema from a probe of the extractor's own SQL, memoised for the whole
read, describing the result and never the catalog - so a view, an alias and a computed column describe
correctly, and a query builder's paging is never part of what is described. withSchema() stays as an override
that always wins and costs no query.
How the probe runs depends on what the driver can answer:
- MySQL prepares your SQL as written and reads the statement's own metadata. Nothing is executed.
- PostgreSQL and SQLite run a zero-row query,
SELECT * FROM (<your sql>) flow_describe WHERE 1=0.
<?php
use function Flow\ETL\Adapter\Doctrine\from_dbal_query;
use function Flow\ETL\DSL\{data_frame, float_schema, int_schema, schema, to_output};
// Derived: the column names come through DBAL, the column types from the driver's own metadata.
data_frame()
->read(from_dbal_query($connection, 'SELECT id, amount * 2 AS total FROM orders WHERE id > :min', ['min' => 10]))
->write(to_output())
->run();
// Declared: withSchema() short-circuits the probe entirely.
data_frame()
->read(
from_dbal_query($connection, 'SELECT id, amount * 2 AS total FROM orders')
->withSchema(schema(int_schema('id'), float_schema('total'))),
)
->write(to_output())
->run();
What each driver answers:
- PostgreSQL (
pgsql) and MySQL (mysqli) report real column types, mapped to Flow types; every derived column is nullable. MySQL types are read off a prepared statement, so its type probe executes nothing. - SQLite (
sqlite3,pdo_sqlite) has no per-column result types, so every column describes as nullablestringand the read casts its values to match - the same model DuckDB uses forsqlite_query(). - A parameterised query describes normally: DBAL's
:nameand?placeholders are rewritten to the driver's own dialect and the probe bindsnullat every position (an empty list for an array-typed parameter), so it never sees the values you passed. - Duplicate output column names -
SELECT * FROM a JOIN b ON a.id = b.idwhere both tables haveid- collapse to the last one on PostgreSQL and MySQL, exactly as the row itself collapses them. On SQLite the zero-row probe makes the driver rename the second toid:1, so the schema carries a column the rows do not; alias the columns apart, or declare the schema, when a SQLite query selects a name twice.
A query that cannot be described does not read at all:
| What is wrong | What you get |
|---|---|
| a table or column the query names is missing | DBAL's TableNotFoundException / InvalidFieldNameException (SQLite: DriverException) - what the read throws |
| any other refusal (a syntax error, a multi-statement query on SQLite or PostgreSQL) | SchemaNotDerivableException, getPrevious() is the DBAL exception |
a driver without a result-type probe - pdo_pgsql and pdo_mysql hand out a plain PDO |
SchemaNotDerivableException - use the native pgsql:// or mysqli:// DSN |
a column whose driver type Flow has no type for - on PostgreSQL arrays, money, inet, ranges and interval on this route (the PostgreSQL adapter above maps more of them); on MySQL BIT and GEOMETRY |
SchemaNotDerivableException |
->withSchema(...) skips the probe; it helps the last two rows, and the second one when the query runs as written.
Inferring Types from an Array
An array source has no schema of its own, so ask it to infer one:
<?php
use function Flow\ETL\DSL\data_frame;
use function Flow\ETL\DSL\from_array;
use function Flow\ETL\DSL\infer_schema;
data_frame()
->read(from_array([['id' => '1', 'customer' => 'Norbert', 'total' => '10.5', 'paid' => 'true']])
->inferSchema(infer_schema()))
->printSchema();
// schema
// |-- id: ?string
// |-- customer: ?string
// |-- total: ?string
// |-- paid: ?string
from_array() infers from the PHP value, so a string cell stays a string. Narrowing text to a richer type is a
file-source behaviour: from_csv() and from_json() read each cell as text and narrow it to the richest type
that parses, so '10.5' read from a CSV infers as ?float.
A declared schema ("Declaring the Source Schema" above) is still the stronger option when you know the types; inference is what answers when you do not. Every inferred column is nullable - inference never claims a column cannot be null.
Schema Validation Strategies
Flow PHP provides three built-in validation strategies:
- StrictValidator - Rows must exactly match the schema; extra entries cause validation failure
- SelectiveValidator - Only validates entries defined in schema; ignores extra entries
- EvolvingValidator - Allows missing and extra entries as long as nullability permits it
By default, DataFrame uses StrictValidator, but you can specify a different validator as the second parameter to
DataFrame::match().
Basic Schema Matching
Use DataFrame::match() to validate data against a schema:
<?php
use function Flow\ETL\DSL\{data_frame, from_array, schema, int_schema, str_schema, bool_schema, to_output};
use Flow\ETL\Schema\Metadata;
data_frame()
->read(from_array([
['id' => 1, 'name' => 'Product 1', 'active' => true],
['id' => 2, 'name' => 'Product 2', 'active' => false],
['id' => 3, 'name' => 'Product 3', 'active' => true]
]))
->match(
schema(
int_schema('id', $nullable = false),
str_schema('name', $nullable = false),
bool_schema('active', $nullable = false, Metadata::empty()->add('key', 'value')),
)
)
->write(to_output(false, Output::rows_and_schema))
->run();
Schema Validation with Selective Strategy
<?php
// Only validate defined fields, ignore extra ones
data_frame()
->read(from_array([
['id' => 1, 'name' => 'John', 'extra_field' => 'ignored'],
['id' => 2, 'name' => 'Jane', 'another_extra' => 'also ignored'],
]))
->match(
schema(
int_schema('id'),
str_schema('name')
),
schema_selective_validator() // Only validate id and name, ignore other fields
)
->write(to_output())
->run();
Found a typo or an outdated section? Edit this page on GitHub