Some columns hold more than one value. A BigQuery STRUCT column or a Trino ROW column
holds a small record with named fields. A field can hold another record, or a list of
records. This page calls all of these columns STRUCT columns.
Data lineage tells you where the value of each column comes from. A STRUCT column adds one
more question:
Does this value come from the whole STRUCT column, or from one field inside it? And
from which field?
GSP Java answers this question in the same way for BigQuery, Athena, Presto and Trino. This
page shows the answer with small SQL examples and the real lineage output of GSP Java. It
also explains why the answer is correct.
Who this page is for
You use the data lineage of GSP Java (DataFlowAnalyzer) on SQL that reads or writes
STRUCT columns. You want to know what the lineage looks like, and why it looks like that.
You do not need to know how GSP works inside.
Verified against
GSP Java version
4.2.14, not released yet. The samples ran on a build of the master branch at commit 3ca283f96. That build still reports TBaseType.versionid4.2.13. The released 4.2.13 and older versions give different lineage for STRUCT columns.
Page last verified
2026-10-02
Samples
1 Java program, run on 41 SQL examples
Java versions
the samples give the same output on JDK 8, 17 and 21
Every block of program output on this page was copied from a real run of that build. None
of it is written by hand. See Refreshing this page for how to
bring the page forward to a newer release.
All examples on this page use one Java program, StructLineage, from
section 2.4. Put the GSP jar on the class path (on Windows, use ; instead of
: between the entries):
customer is a STRUCT column. Its value is a record with three fields: name,
phone and address.
address is a field, and it is a STRUCT too. It has its own field, city. We say that
address is a nested STRUCT.
items is an ARRAY of STRUCTs: a list of records. Each record in the list is an
element of the array. Each element has the fields item_name and qty.
A query reads a field with dots: customer.name, customer.address.city. It reads the
elements of an array with UNNEST, or with a subscript such as items[OFFSET(0)].
Both tables read their data from files in s3://my-bucket/events/. GSP shows these files as
a path, and the path gives the data of each column that the table declares, and of each field
declared inside a column. So the lineage of the table events starts with one edge per column,
events.event_id <- 's3://my-bucket/events/'.event_id and events.payload <- 's3://my-bucket/events/'.payload,
and one per declared field, such as events.payload.address.city <- 's3://my-bucket/events/'.payload.address.city.
A query that reads payload.address.city can therefore be traced back to the files.
Most analytic databases have a type like this, with other names and another syntax:
Database
STRUCT type
Array of STRUCTs
Read a field
Read the elements of an array
BigQuery
STRUCT<city STRING>
ARRAY<STRUCT<...>>
customer.address.city
UNNEST(items), items[OFFSET(0)]
Trino, Presto
ROW(city VARCHAR)
ARRAY(ROW(...))
payload.address.city
UNNEST(...), items[1]
Athena
STRUCT<city: STRING> in CREATE EXTERNAL TABLE
ARRAY<STRUCT<...>>
payload.address.city
UNNEST(...), items[1]
Databricks, Spark SQL, Hive
STRUCT<city: STRING>
ARRAY<STRUCT<...>>
customer.address.city
explode(items), items[0]
Snowflake
VARIANT or OBJECT, with no declared fields
ARRAY
customer:address.city
FLATTEN(...), items[0]
Redshift
SUPER, with no declared fields
SUPER
o.customer.address.city
FROM orders_raw AS o, o.items AS i
PostgreSQL
a composite type, CREATE TYPE ... AS (...)
an array of it, customer_t[]
(customer).address
unnest(items), items[1]
Oracle
an object type, CREATE TYPE ... AS OBJECT (...)
VARRAY, nested table
o.customer.address.city
TABLE(o.items)
GSP Java builds the STRUCT lineage that this page describes for BigQuery, Athena, Presto
and Trino, and the same tree of fields for the STRUCT columns of Databricks, Hive and
Spark SQL (section 12). Section 13 shows what GSP gives today for the
other databases.
In GSP lineage, a table has nodes, and edges connect the nodes. A normal column is
one node. A STRUCT column is a small tree of nodes:
1 2 3 4 5 6 7 8 91011
orders_raw
├── order_id
├── customer the column
│ ├── customer.name a field
│ ├── customer.phone a field
│ └── customer.address a field that is a STRUCT
│ └── customer.address.city a field of that field
└── items the column (an ARRAY)
└── items[*] an element of the array
├── items[*].item_name a field of an element
└── items[*].qty
There are three kinds of nodes:
A column node is a column that the table declares: customer, items.
A field node is one field of a column, at any depth: customer.name,
customer.address, customer.address.city. A field that is a STRUCT, such as
customer.address, has its own node too.
An element node stands for the elements of an array: items[*] is an element of
items, and items[*].item_name is the field item_name of an element. GSP writes [*]
because a query can read any element. Which element it reads is known only when the query
runs.
Run the sample program on the table alone, and GSP lists the nodes of the table:
The name of a node is its path, from the column down to the field, with dots between the
steps:
Node name
Meaning
customer
the column customer
customer.address
the field address of the column customer
customer.address.city
the field city of the field address of the column customer
items[*]
an element of the array column items
items[*].item_name
the field item_name of an element of items
In the lineage output, a field node is in the list of columns of its table, after the
column that it belongs to. The first part of its name is the name of that column.
GSP follows five rules when it turns a read into an edge:
A read of the whole column comes from the column node.
A read of a field of a table comes from exactly that field node. It does not come
from the whole column, and it does not come from the other fields. This holds for a read
through a CTE or a subquery too; see section 9.
A read of a column or of a field gives the same edge with or without the DDL of the
table. The DDL adds the nodes of the fields that the query does not read. It also tells
GSP which fields exist, so GSP can list the fields of customer.*
(section 6) and ignore a field that does not exist (section 4.2).
When GSP cannot prove which table owns the column, it draws no edge.
GSP does not invent a node or an edge that the SQL does not prove.
The sections below show each rule with an example. Section 11 explains why these
rules give correct lineage.
This program runs DataFlowAnalyzer on one or more SQL files and prints three things: the
nodes of each table and view, the edges, and the messages of the analysis.
importgudusoft.gsqlparser.EDbVendor;importgudusoft.gsqlparser.dlineage.DataFlowAnalyzer;importgudusoft.gsqlparser.dlineage.dataflow.model.Option;importgudusoft.gsqlparser.dlineage.dataflow.model.xml.column;importgudusoft.gsqlparser.dlineage.dataflow.model.xml.dataflow;importgudusoft.gsqlparser.dlineage.dataflow.model.xml.error;importgudusoft.gsqlparser.dlineage.dataflow.model.xml.relationship;importgudusoft.gsqlparser.dlineage.dataflow.model.xml.sourceColumn;importgudusoft.gsqlparser.dlineage.dataflow.model.xml.table;importjava.io.File;publicclassStructLineage{publicstaticvoidmain(String[]args)throwsException{EDbVendorvendor=EDbVendor.valueOf(args[0]);// for example dbvbigqueryFile[]files=newFile[args.length-1];// the SQL files, in orderfor(inti=1;i<args.length;i++){files[i-1]=newFile(args[i]);}Optionoption=newOption();option.setVendor(vendor);option.setSimpleOutput(true);// only source -> target, no intermediate result setsoption.setOutput(false);// keep the result in memory, do not write XMLDataFlowAnalyzeranalyzer=newDataFlowAnalyzer(files,option);analyzer.generateDataFlow();dataflowdf=analyzer.getDataFlow();// 1. The nodes: each table and view, with its columns and field nodesfor(tablet:df.getTables()){System.out.println("table "+t.getName()+": "+names(t));}for(tablev:df.getViews()){System.out.println("view "+v.getName()+": "+names(v));}// 2. The edges: where the value of each target comes fromfor(relationshipr:df.getRelationships()){if(!"fdd".equals(r.getType())){continue;// fdd = the value comes from the sources}StringBuilderline=newStringBuilder(r.getTarget().getParent_name()+"."+r.getTarget().getColumn()+" <-");for(sourceColumns:r.getSources()){line.append(' ').append(s.getParent_name()).append('.').append(s.getColumn());}System.out.println(line);}// 3. The messages, for example an orphan columnfor(errore:df.getErrors()){System.out.println("message: "+e.getErrorMessage());}analyzer.dispose();}staticStringnames(tablet){StringBuildernames=newStringBuilder();for(columnc:t.getColumns()){if("RelationRows".equals(c.getName())){continue;// a system column, not data}names.append(names.length()==0?"":", ").append(c.getName());}returnnames.toString();}}
How to read the output:
table orders_raw: order_id, customer, ... lists the nodes of a table. view v: ...
lists the columns of a view.
v.city <- orders_raw.customer.address.city is an edge: the value of the view column
v.city comes from the node customer.address.city of the table orders_raw.
A line that starts with message: is a message of the analysis, such as an orphan
column (section 10.1).
The program uses the simple output (Option.setSimpleOutput(true)): each edge goes
straight from a source node to a target column. Without it, GSP also shows the
intermediate result sets of the query, such as RS-1, between them.
Most examples on this page run twice. The tab With the DDL runs the query after the
table file of its database from section 1.2: orders_raw.sql, events.sql or
events_athena.sql. For BigQuery:
table orders_raw: customer
view v: c
v.c <- orders_raw.customer
v.c holds the whole record, so it comes from the column node customer (rule 1). There
is one edge, not one edge for each field: the fields are inside customer, and the tree
already shows that.
The query reads only the city, so the edge goes to exactly the node
customer.address.city (rule 2). It does not go to customer, and it does not go to
customer.phone.
Without the DDL, GSP still knows that customer is a column and not a table: customer is
not a table in the FROM clause, so customer.address.city must be a column with fields.
GSP creates the nodes on the path to the field, customer and customer.address, and it
draws the edge from the field.
customer.address is a record with its own field, city. The edge goes to the node
customer.address. It does not go to customer.address.city, and it does not go to the
other fields of customer, such as customer.phone.
o is the alias of orders_raw. GSP removes the alias and reads the rest of the name as
the column and its fields. The result is the same node as in section 3.2.
Trino first tries to read the start of a name with dots as a table in the FROM clause:
the alias e here, or a qualified name such as sch.events. The rest of the name is then a
column and its fields. When no table matches, the first part is a column (payload). GSP
reads the names in the same way.
The aggregate of a BigQuery PIVOT can read a field. Here COUNT(customer.name) reads the
field customer.name, so the column 1 that the PIVOT makes comes from that field and
from order_id, the column whose values name the new columns. With the DDL, the other
column of the table, items, is passed on as a grouping column, which GSP prints as ITEMS:
With the DDL, GSP knows that customer has no field email, and BigQuery would reject
this query. So GSP creates no node and no edge, in the same way as for a column that the
table does not declare (rule 5). Without DDL, GSP cannot know this, and the query is the
only information that it has.
The edge goes to payload.items[*].name. All elements of an array have the same fields,
and lineage shows where data comes from, not which row: the field name of the first
element is the field name of an element.
A BigQuery subscript reads the element in the same way, so items[OFFSET(0)].item_name
comes from items[*].item_name:
With the DDL, GSP knows the fields: the view gets one column for each field of payload,
and each column comes from its field node. Without DDL, GSP does not know the fields. The
view gets one column *, and it comes from the column node payload, which stands for all
of its fields.
BigQuery gives the same result with the DDL. Save this query as star.sql:
The new STRUCT buyer has two fields, and each field has its own source. So the view gets
one node for each field, buyer.name and buyer.id, and each one has its own edge.
A Trino ROW value built with CAST(ROW(...) AS ROW(...)) gives the view column one field
node for each field of the ROW type, and each field node gets the edge of its own value:
The edge goes to the field node customer.address.city, as for a direct read. The CTE
passes on the column orders_raw.customer unchanged, so reading customer.address.city
from the CTE reads that field of orders_raw.customer.
The DDL still counts here. When it shows that the column passed on by the CTE has no such
field, for example because it is an INT64 column, GSP draws no edge, as for a direct read
(section 4.2).
When the CTE passes on a field, the read of a field below it goes to that field too:
Without any DDL, both orders and returns could have a column customer. A guess could
be wrong, and a wrong edge looks the same as a right one. So GSP draws no edge (rule 4). It
reports customer as an orphan column: a column with no proven table. The node goes
into the special table pseudo_table_include_orphan_column.
When a DDL shows which table has the column, GSP uses that table:
table sales.orders: order_id
view v: id
v.id <- sales.orders.order_id
sales.orders is a table in the FROM clause, so sales.orders.order_id is the column
order_id of that table. It is not a column sales with the fields orders and
order_id. A name with dots is a field path only when its first part is a column.
It follows the model of the database. BigQuery itself treats a STRUCT column and its
fields as two kinds of things. Its view INFORMATION_SCHEMA.COLUMNS lists the columns
(customer). Its view INFORMATION_SCHEMA.COLUMN_FIELD_PATHS lists each column and each
field in it as a path (customer, customer.name, customer.address,
customer.address.city), and it lists the policy tags of each path: a field such as
customer.phone can have its own access rules. The GSP tree has the same column nodes and
the same field paths, so a rule on a field and the lineage of that field meet on the same
node.
Exact edges answer impact questions. Suppose that you plan to change the field
customer.phone, and you ask: which views use it? Compare the tree with two simpler
models:
Model
SELECT customer.address.city comes from
SELECT customer comes from
Problem
Every read comes from the whole column
customer
customer
Too coarse. The view that reads only the city also seems to use the phone number.
Too many edges. A STRUCT with 50 fields gives 50 edges for one read, and a read of customer.address is hard to show without its sibling fields.
The tree (GSP)
customer.address.city
customer
With the tree, the answer is exact: a view uses customer.phone when it reads
customer.phone, or when it reads the whole customer, which contains the phone. A view
that reads only customer.address.city does not use it. To find "contains", follow the
tree: customer.phone is below customer.
The lineage does not depend on the metadata that you have. A read of a column or a
field gives the same edge with and without the DDL (rule 3). Without the DDL, GSP finds the
column and the fields from the query, in the way that the database reads the names. With
the DDL, GSP also lists the fields that the query does not read.
No guesses. When the SQL does not prove which table owns a column, GSP reports an
orphan column and draws no edge (rule 4). When the DDL says that a field does not exist,
GSP does not create it (rule 5). A missing edge is a problem too, but a wrong edge is a
problem that nobody can see in the output, so GSP draws an edge only when the SQL proves
it.
The tree needs only three things: a column, the names of its fields, and the elements of
an array. Every STRUCT type in section 1.3 has them. They only use other
syntax to read them:
Database
Read in SQL
Node in the tree
BigQuery
customer.address.city
customer.address.city
BigQuery
i.item_name, with UNNEST(items) AS i
items[*].item_name
Trino, Presto, Athena
payload.address.city
payload.address.city
Trino, Presto, Athena
payload.items[1].name
payload.items[*].name
So the same tree, with the same node names, can describe a STRUCT column of each of these
databases. Today GSP builds it for BigQuery, Athena, Presto and Trino, and for the STRUCT
columns of Databricks, Hive and Spark SQL.
In Databricks, Hive and Spark SQL, each read of a field comes from its field
node, as in BigQuery. Databricks and Hive print the same lines:
For Snowflake, PostgreSQL, Oracle and Redshift (section 1.3), GSP does not
build the STRUCT tree yet. Here is what GSP gives today for the same kind of query.
Snowflake and PostgreSQL: every read of a field comes from the whole column. This
is true, but not exact:
These shapes do not get the exact lineage of this page yet. Each one shows the real output
of the build in the stamp at the top of this page.
A BigQuery UNNEST without an alias, without DDL, gives no edge. Without the element
type, nothing proves whether item_name is a column of the table or a field of the element,
so GSP reports an orphan column (rule 4). With the DDL, the read goes to the field:
A BigQuery field read through SELECT *, without DDL, goes to the whole column
customer, which GSP prints as CUSTOMER, not to the field. With the DDL, it goes to the
field node:
Runs the analysis on the SQL files, in order, and returns the result, a dataflow. DataFlowAnalyzer(String sqlContent, Option option) does the same for SQL text.
dataflow
getTables(), getViews()
The tables and views, as table objects.
dataflow
getRelationships()
The edges, as relationship objects.
dataflow
getErrors()
The messages of the analysis, as error objects.
table
getName(), getColumns()
The name, and the nodes: the columns, each followed by its field nodes.
column
getName()
The name of a node: customer, customer.address.city, items[*].item_name.
relationship
getType()
fdd when the value of the target comes from the sources.
relationship
getTarget(), getSources()
The target, a targetColumn, and the sources, sourceColumn objects.
targetColumn, sourceColumn
getParent_name(), getColumn()
The table or view of the node, and the name of the node.
This page is written by hand, but every output on it is measured. To bring it forward to a
newer GSP release:
1. Read the version from TBaseType.versionid and TBaseType.releaseDate in
gsp_java_core/src/main/java/gudusoft/gsqlparser/TBaseType.java.
2. Run every example again. Compile the program of section 2.4 against the
new jar. For each example, run it as section 2.4 shows: with the table file of
section 1.2 for the tab With the DDL, and alone for the tab Without DDL
or for an example with one output block. Paste the new output. Do not edit an output block
by hand. An example marked -- Trino, Presto, Athena must print the same lines for each of
the three databases.
A script does all of this. It compiles the program as the page prints it, runs every
example, and compares each output block line by line. With --fix, it writes the new
output into the page and lists each change for you to review:
3. Check the limits. When a limit in section 14 or a database in
section 13 gives new lineage, move its example to the section where it now
belongs, and update the text.
4. Update the stamp at the top: the version, its release date, today's date, and the
number of examples. Change it only after you have run the examples on the new build.