Overview
Since its inception in the 1950s, the mainframe has remained one of the most popular systems in the enterprise. Even today, over 70% of Fortune 500 companies and 50% of Global 2000 companies run one or more mainframe systems and continue to maintain COBOL and DB2, despite having many other more modern options in the cloud.
Despite its popularity, the mainframe remains one of the toughest areas for data governance teams, and most of it is due to traditional data lineage challenges as well as modern AI not handling COBOL at the required accuracy and scale. Ironically, that is usually where regulators, auditors, and modernization programs demand the most precise answers about where data comes from and how it was transformed.
This article is a technical deep dive into how Foundational solves that problem — by parsing and analyzing the mainframe source code itself. We'll walk through real COBOL, JCL, copybooks, and DB2 DDL from AWS CardDemo, an open-source mainframe credit-card application, and show how static analysis turns them into field-level lineage.
The problem: lineage goes dark at the mainframe boundary
Core banking still runs on the mainframe
Ask where the world's money actually moves and the answer is still, to a remarkable degree, an IBM Z machine running z/OS — IBM's mainframe hardware line and its operating system. Banks, insurers, card networks, government agencies, airlines, and large retailers keep their systems of record on the mainframe because it delivers what nothing else has matched at that scale: decades of accumulated, battle-tested business logic running with extreme reliability and throughput. Most of that logic is written in COBOL, a programming language created in 1959 specifically for business data processing. It remains the workhorse of banking and insurance back ends: verbose by modern standards, but purpose-built for one job — reading fixed-format records, applying business rules, and writing fixed-format records, millions of times a night.
That environment is far more than "COBOL code". A typical z/OS estate combines several kinds of assets, each with a rough equivalent in the modern stack:
COBOL programs — the business logic. They run either as batch jobs or as online transactions under CICS or IMS, the mainframe's transaction-processing middleware — roughly what an application server is to a Java shop.
Copybooks — shared record layouts pulled into programs with the
COPYstatement: the mainframe's equivalent of a C header file full of struct definitions, or a schema file shared between services. They define the physical structure of files and interfaces field by field; each field's type and length is declared with aPICclause.JCL (Job Control Language) — think of a shell script crossed with a CI pipeline definition. Each job chains program steps, wires every program to the physical files it reads and writes through
DD("data definition") statements, and invokes system utilities (DFSORT,IEBGENER,IDCAMS,DSNTIAUL— sort, copy, catalog-management, and database-unload utilities).Datasets — mainframe files. VSAM files are indexed, keyed files that behave like lightweight database tables (a KSDS is keyed for direct lookup and update; an ESDS is append-only); QSAM files are plain sequential files; and generation data groups (GDGs) keep numbered versions of the same dataset — think date-partitioned outputs, one generation per run.
DB2 for z/OS — IBM's relational database on the mainframe: tables accessed through SQL embedded directly in COBOL source (
EXEC SQL) or through unload/load utilities.Everything around it — job schedulers (Control-M, CA-7, IWS — the estate's cron and orchestrator), source-code management systems that predate git (Endevor, ChangeMan ZMF), and third-party vendor utilities that are called from JCL but whose source you'll never see.
Data constantly crosses the boundaries between these components: a DB2 table is unloaded to a flat file, a COBOL batch job reads that file and rewrites a VSAM master, an extract lands somewhere a Python job picks it up. Understanding how a single field travels through all of that is the problem this article is about.
Follow one field through a mainframe pipeline
Take a concrete question a bank's data team might face: "Our account balances feed the regulatory capital calculation. What exactly updates ACCT-CURR-BAL in the account master, and from which inputs?"
On the mainframe, the honest answer spans several assets. In CardDemo, the nightly posting job POSTTRAN runs a COBOL program called CBTRN02C, which posts the day's card transactions to the master files:
Reading left to right: the day's transactions arrive as a plain sequential file, DALYTRAN.PS. For each transaction, CBTRN02C looks up the card number in the cross-reference file CARDXREF to find the owning account, then validates the transaction against the account master ACCTDATA.
Valid transactions are appended to the posted-transactions file TRANSACT, the account's balances in ACCTDATA are updated in place, and running per-category totals are updated in TCATBALF. Anything that fails validation lands in the day's reject file, DALYREJS.
So a single transaction amount arrives in a flat file, passes through validation logic that reads two other VSAM files, and ends up — transformed differently each time — in three separate datasets. None of this is a SQL query. All of it is field-level data flow that someone, eventually, will need to explain to an auditor or untangle for a modernization project.
Manual mainframe lineage is no longer an option
Three situations force organizations to answer this kind of question precisely:
Regulatory provenance — Frameworks like BCBS 239 — the Basel Committee's principles for risk-data aggregation and reporting — don't use the word "lineage", but in practice supervisors and auditors expect banks to demonstrate, down to the critical data element, where reported numbers come from and what transformed them. A vague answer that points at the mainframe won't satisfy them; "this balance is the accumulation of validated transaction amounts from the daily feed, posted by this program, gated by this credit-limit check" will.
Modernization — Every mainframe modernization program — rewriting a component in Java, moving a workload to the cloud, retiring a feed — starts with the same question: what breaks if we change this? Without lineage, teams reverse-engineer data flow by reading code and interviewing whoever is left who remembers. That's slow enough to stall projects.
Understanding how a field is actually used and calculated — Before changing the semantics of a field (say, making customer currency multi-valued), you need every downstream consumer: which programs read it, what they compute from it, and which reports it lands in. On mainframes this includes consumers connected only through intermediate files, which no catalog records.
Why existing lineage tools don't solve this
Most data lineage products are built on two sources of truth: SQL query history and warehouse metadata. Both go dark at the mainframe boundary, for structural reasons:
Most mainframe data movement isn't SQL — The CardDemo posting flow above touches six datasets and issues zero SQL statements — it's all COBOL READ/WRITE/REWRITE against VSAM and sequential files. A tool that reconstructs lineage from query logs has literally nothing to parse. This isn't an edge case; file-based batch is the backbone of mainframe processing.
Even the SQL paths lose track of where the data lands — A SELECT names its sources, never its destination — and on the mainframe, the destination usually lives outside the database entirely, in JCL. Consider CardDemo's reference-data extract job TRANEXTR, which unloads a DB2 table to a flat file using DSNTIAUL, IBM's sample unload utility. There is no COBOL involved at all — the SQL lives directly inside the JCL. Two pieces of plumbing make the excerpt below readable: TSO (Time Sharing Option) is z/OS's command environment — in effect, the mainframe's shell — and IKJEFT01 is the standard program for running TSO commands inside a batch job. The SYSTSIN stream near the bottom holds those TSO commands, and it is what actually launches DSNTIAUL. DSNTIAUL in turn reads the SQL to run from the SYSIN stream (excerpt; line numbers added for reference):
1 //STEP40 EXEC PGM=IKJEFT01,COND=(0,NE)
2 //SYSREC00 DD DISP=(NEW,CATLG,DELETE),
3 // DSN=&HLQ..TRANTYPE.PS
4 //SYSIN DD *
5 SELECT CAST(CONCAT(CONCAT(
6 TR_TYPE
7 ,CAST(TR_DESCRIPTION AS CHAR(50))
8 )
9 ,REPEAT('0',8)
10 ) AS CHAR(60))
11 FROM CARDDEMO.TRANSACTION_TYPE
12 ORDER BY TR_TYPE;
13 /*
14 //SYSTSIN DD *
15 DSN SYSTEM(DAZ1)
16 RUN PROGRAM(DSNTIAUL) -
17 PLAN(DSNTIAUL) -
18 PARMS('SQL')
19 /*
To read the code snippet above: line 1 runs IKJEFT01. Lines 2-3 (SYSREC00) allocate a brand-new dataset, TRANTYPE.PS — the unload target. Line 4 (//SYSIN DD *) opens an in-stream input block: lines 5-12 are the SQL statement itself, embedded in the job, and the /* on line 13 terminates the block. Lines 14-19 (SYSTSIN) hold the TSO commands: connect to the DB2 subsystem DAZ1 (a subsystem is a named DB2 instance), then run DSNTIAUL (the trailing - on lines 16-17 is just line continuation). The layout looks inside-out — the SQL on lines 5-12 sits above the command on line 16 that runs it — but that's normal JCL: DD statements are declarations, not sequential steps, so their order within the step doesn't matter.
Now the lineage aspect: this SELECT does show up in the query history — DB2 will record that the statement ran, and even which job ran it. What the database cannot record is where the result set went: the sink is the SYSREC00 DD statement on line 2, a dataset allocation that exists only in JCL. From DB2's perspective, the rows simply left. The file TRANTYPE.PS then feeds downstream report programs, and that entire onward chain is invisible to any database-side monitoring.
Foundational on CardDemo: the reference-data flow from the excerpt above — the DB2 table TRANSACTION_TYPE (subsystem DAZ1, schema CARDDEMO) with TR_TYPE selected, flowing into the flat file TRANTYPE.PS. Foundational also shows the relevant code snippets from the JCL file.
Query history shows final values, not logic. Static SQL embedded in COBOL is precompiled and bound into packages; what executes at runtime carries host variables (:INPUT-REC-DESC), not the COBOL logic that produced their values. Even with full statement-level capture, you'd see that a column was set — never why, or from which upstream fields the value was computed.
And these approaches don't just fall short — they demand risky access to get even that far. Everything above shows that watching the database cannot reconstruct mainframe lineage. Yet to deliver even its partial picture, a database-side tool must connect to the production DB2 that holds core banking data, read its catalog metadata, and run queries or monitoring traces against live systems. Those are the most tightly guarded systems in the enterprise, and putting third-party workload on them is exactly the kind of operational risk mainframe security teams exist to prevent — so that access is almost never granted. An approach that is blind to file-based data flow and needs production database access to show anything at all is, for most mainframe estates, dead on arrival.
Static analysis: reading the code instead of watching the database
If the logic lives in code, the code is where lineage should come from. Static analysis — parsing source into structured form and tracing data flow through it — has properties that runtime observation fundamentally lacks:
It covers every kind of data movement. File I/O, VSAM access, embedded SQL, utility invocations in JCL — anything expressed in code is analyzable, not just what happens to be a query.
It explains why, not just what. The lineage edge carries the transformation itself: this balance is
credit - debit + amount, computed at this line of this program. That's the difference between an audit trail and a hint.It sees all paths, not just recent ones. A quarter-end-only branch, an error-handling path, code deployed yesterday that hasn't run yet — query history misses all of these by construction. Code analysis shows every path that can execute.
It needs no access to production systems. The input is source code — COBOL, copybooks, JCL, DDL (Data Definition Language: the SQL statements that define tables and schemas) — not database credentials or traces. For security-sensitive mainframe estates this is frequently the only viable option.
How Foundational extracts mainframe lineage
Foundational's architecture deliberately mirrors a compiler toolchain, because the problem has the same shape: many separately-owned compilation units that must be linked into one consistent whole.
The "compiler" analyzes one repository at a time: it parses the source, builds ASTs (abstract syntax trees — the structured, machine-readable form of the code that a parser produces), traces data flow from read-sources to write-sinks, and emits a lineage snapshot. Where the repository references things it doesn't define — a table whose DDL lives in the database team's repo, a stored procedure defined elsewhere — the snapshot records partial facts, exactly like an object file records unresolved symbols.
The "linker" then stitches all snapshots together: it resolves those unresolved references across repositories, unifies entity names, expands star queries (
SELECT *) into explicit column lists, and produces the final, consistent lineage graph.
This is the same architecture that lets conventional compilers scale to codebases of arbitrary size, and it works for the same reason: each unit is analyzed once, deeply, in isolation — and global consistency is a separate, well-defined resolution phase.
The "compiler": from COBOL to a lineage snapshot
Let's walk through the actual CardDemo posting program, CBTRN02C.cbl.
Step 1: Parse to an AST. Enterprise COBOL is not a trivial parse. Source lines are fixed-format: each of the 80 character columns of a line has an assigned meaning — columns 1-6 hold a sequence number, column 7 is an indicator (an * there turns the whole line into a comment), the code itself lives in columns 8-72, and columns 73-80 are reserved for identification. On top of that come COPY members that must be expanded before the program even makes sense, REDEFINES overlays, reference modification (COBOL's substring slicing), and EXEC SQL blocks that are a different language entirely — handled on real systems by the DB2 precompiler or the integrated SQL coprocessor.
Below is the paragraph of CBTRN02C that updates the account balances — in COBOL, a "paragraph" is a named block of statements, roughly a small function (condensed: file-status checks and error handling elided):
2800-UPDATE-ACCOUNT-REC.
ADD DALYTRAN-AMT TO ACCT-CURR-BAL
IF DALYTRAN-AMT >= 0
ADD DALYTRAN-AMT TO ACCT-CURR-CYC-CREDIT
ELSE
ADD DALYTRAN-AMT TO ACCT-CURR-CYC-DEBIT
END-IF
REWRITE FD-ACCTFILE-REC FROM ACCOUNT-RECORD.
Simplified, the AST for that paragraph looks like:
PARAGRAPH 2800-UPDATE-ACCOUNT-REC
├── ADD
│ source: DALYTRAN-AMT
│ target: ACCT-CURR-BAL (read-modify-write)
├── IF condition: DALYTRAN-AMT >= 0
│ ├── then: ADD DALYTRAN-AMT → ACCT-CURR-CYC-CREDIT
│ └── else: ADD DALYTRAN-AMT → ACCT-CURR-CYC-DEBIT
└── REWRITE
record: FD-ACCTFILE-REC FROM ACCOUNT-RECORD
Step 2: Resolve symbols through copybooks. DALYTRAN-AMT isn't declared in the program — it comes from copybook CVTRA06Y, pulled in with COPY CVTRA06Y.:
01 DALYTRAN-RECORD.
05 DALYTRAN-ID PIC X(16).
05 DALYTRAN-TYPE-CD PIC X(02).
05 DALYTRAN-CAT-CD PIC 9(04).
...
05 DALYTRAN-AMT PIC S9(09)V99.
...
The copybook is the schema. It tells the analyzer that DALYTRAN-AMT is a signed amount with nine integer and two implied decimal digits — 11 digits of zoned decimal, with no physical decimal point in the record. And because the same copybook is used by every program that touches this file, it is also what lets the linker later recognize that two programs are reading and writing the same record structure. Likewise, ACCOUNT-RECORD comes from copybook CVACT01Y and describes the 300-byte account master record.
Step 3: Trace read-sources to write-sinks. The analyzer walks the AST and connects the dots: DALYTRAN-RECORD is populated by READ DALYTRAN-FILE INTO DALYTRAN-RECORD; ACCOUNT-RECORD is written back by the REWRITE ... FROM ACCOUNT-RECORD. Along each path from a read to a write, it collects every assignment, arithmetic statement, and governing condition, and composes them into the end-to-end transformation logic for that source-to-sink pair — so the ADD and the IF from the AST above become a single lineage edge from DALYTRAN-AMT to ACCT-CURR-CYC-CREDIT that carries both the arithmetic and the condition under which it applies. For this one program the extracted field-level lineage includes:
Source field | Sink field | Transformation |
|
| additive accumulation ( |
|
|
|
|
|
|
|
| additive accumulation |
|
| field-by-field |
|
|
|
|
| reformatted via |
|
| record copy + generated validation trailer |
Two details here matter to anyone who has tried to do this with regexes. First, the processing timestamp flows through a REDEFINES overlay: FUNCTION CURRENT-DATE is split into components and reassembled into DB2 timestamp format through a redefined byte layout. Tracking that requires modeling COBOL storage semantics, not text.
Second, the credit-limit check — COMPUTE WS-TEMP-BAL = ACCT-CURR-CYC-CREDIT - ACCT-CURR-CYC-DEBIT + DALYTRAN-AMT, then compared against ACCT-CREDIT-LIMIT — is a control dependency. It decides whether the transaction posts but contributes no bytes to the posted records; its outcome only selects the reject-reason constant written to the reject file. That is exactly why data flow and control flow must be modeled separately: a sound analyzer keeps the two distinct, since conflating them floods the graph with edges that aren't real provenance.
Step 4: Resolve logical files to physical datasets via JCL. Inside the program, files have logical names: SELECT DALYTRAN-FILE ASSIGN TO DALYTRAN. The name DALYTRAN is a DD name — a symbol resolved from the JCL when the job step runs. The POSTTRAN JCL provides the binding (excerpt):
//STEP15 EXEC PGM=CBTRN02C
//DALYTRAN DD DISP=SHR,
// DSN=AWS.M2.CARDDEMO.DALYTRAN.PS
//ACCTFILE DD DISP=SHR,
// DSN=AWS.M2.CARDDEMO.ACCTDATA.VSAM.KSDS
//DALYREJS DD DISP=(NEW,CATLG,DELETE),
// DSN=AWS.M2.CARDDEMO.DALYREJS(+1)
Only by joining the COBOL analysis with the JCL analysis does the lineage arrive at real, catalog-level dataset names — including normalizing GDG generation syntax like DALYREJS(+1) so that every day's reject file is recognized as the same logical asset. The two views also cross-check each other: CBTRN02C's reject record is 430 bytes (a 350-byte transaction image plus an 80-byte validation trailer), matching the DD's DCB=(RECFM=F,LRECL=430,...) exactly. The same JCL analysis covers utility steps: an IEBGENER step copying SYSUT1 to SYSUT2, or a DSNTIAUL unload writing to SYSREC00, produces lineage edges with no COBOL involved at all.
Foundational on CardDemo: the account master ACCTDATA.VSAM.KSDS with ACCT-CURR-BAL selected and its upstream feed DALYTRAN.PS in the lineage graph; the panel on the right shows the exact COBOL statements behind the edge, from CBACT04C.cbl and CBTRN02C.cbl, linked to the source on GitHub.
The "linker": stitching snapshots into one graph
Mainframe technologies aren't commonly placed in a single repository. DDL lives with the database team, JCL sometimes lives with operations, COBOL lives in one or many application repos, and the modern Java/Python services that consume mainframe extracts live somewhere else entirely. Each repository is analyzed independently — so each snapshot contains references it cannot resolve alone. The linker's job is to resolve them. Here are three examples of what that takes:
1. Star expansion across repositories. Suppose the reference-data unload used a star query — a pattern we see constantly in customer estates:
SELECT * FROM CARDDEMO.TRANSACTION_TYPE
The repository containing this unload job doesn't define the table, so the "compiler" phase can only emit a partial edge: CARDDEMO.TRANSACTION_TYPE.* → TRANTYPE.PS, flagged as unresolved — the analyzer knows data flows, but not which columns. A different repository, owned by the database team, holds the DDL:
CREATE TABLE CARDDEMO.TRANSACTION_TYPE
( TR_TYPE CHAR(2) NOT NULL,
TR_DESCRIPTION VARCHAR(50) NOT NULL,
PRIMARY KEY(TR_TYPE));
The linker joins the two snapshots: it resolves CARDDEMO.TRANSACTION_TYPE to its schema, expands the * into the explicit column list TR_TYPE, TR_DESCRIPTION, and replaces the star edge with two precise column-level edges into the flat file's record layout. Exactly like a linker patching an unresolved symbol with the address from another object file.
2. Qualified-name inference. A COBOL program refers to the table as CARDDEMO.TRANSACTION_TYPE — schema and table, with the database location implied by the runtime environment. A Java service reading the same table over JDBC might reference it through a connection string plus an unqualified name, or a three-part name. If the linker naively treats each spelling as a distinct entity, the lineage graph splits into disconnected islands at exactly the boundary customers care most about — where the mainframe meets modern code. Foundational's linker normalizes and unifies these identities across snapshots so a single table is a single node, no matter which side referenced it.
3. External vs. internal names. Suppose a Java service calls a DB2 stored procedure — CALL CARDDEMO.CALC_INTEREST(?, ?, ?). The caller's snapshot knows only positional arguments. The procedure's own definition, in another repository, declares named parameters and contains the actual logic. The linker matches call sites to definitions and binds arguments to parameters by ordinal position, so lineage flows through the procedure: from the Java variable, into the parameter, through the procedure body, into the table column.
What makes this hard in practice
None of the above is a weekend project, and it's worth being honest about why:
The dialects are unforgiving — Every shop has its quirks: compiler options that change language semantics, vendor precompilers, homegrown copybook conventions, JCL PROCs with symbolic parameters resolved across nested includes.
Third-party utilities have no source — JCL routinely invokes vendor tools — BMC utilities, Syncsort, CA products — whose data-flow semantics must be modeled from their control statements alone, because their internals are proprietary.
The code isn't in git — Mainframe source frequently lives in Endevor, ChangeMan ZMF, or Panvalet, organized as members of partitioned datasets (PDS) with library concatenation rules that affect which copybook version a program actually sees.
There's no open-source to hide behind — "Modern" languages and tools can be validated against thousands of public projects. Realistic COBOL estates are essentially absent from open source — so any mainframe analyzer that hasn't been hardened against real customer codebases will fall over on its first encounter with one.
Lineage from code, not query logs
Mainframes hold the most regulated, highest-stakes data in the enterprise, and its lineage runs through COBOL logic, copybooks, JCL, flat files, and DB2 — mostly outside SQL, and therefore mostly invisible to query-history-based lineage tools. Static code analysis is the right foundation: it covers every kind of data movement, explains the transformation logic rather than just observing outcomes, sees every possible path, and requires access to source code only — not to production systems. Foundational applies a compiler/linker architecture to this problem: per-repository analyzers extract deep, field-level lineage snapshots, and a linking phase stitches them into one consistent graph across mainframe and modern platforms alike — the same architecture that makes ordinary software builds scale.
See it on your own code
If you're planning a modernization program, facing a regulatory lineage requirement, or simply want to know what actually feeds a field in your account master — we'd like to show you Foundational running on your own codebase. Contact us for a demo.
Appendix: a short mainframe glossary
z/OS — the operating system of IBM Z, IBM's mainframe hardware line.
COBOL — the 1959-vintage programming language in which most mainframe business logic is written.
Copybook — a shared source fragment (typically a record layout) included into COBOL programs with
COPY; comparable to a C header file with struct definitions.PIC clause — the part of a COBOL field declaration that defines its type and length;
PIC S9(09)V99means signed, nine integer digits, two implied decimal digits.REDEFINES — a COBOL clause that overlays two field layouts on the same bytes of storage.
JCL — Job Control Language: defines batch jobs as sequences of program steps and binds each program to its files.
DD statement / DD name — the "data definition" line in JCL that binds a program's logical file name to a physical dataset.
Dataset / DSN — a mainframe file / its cataloged name.
QSAM — the access method for plain sequential files.
VSAM — the access method for indexed files; a keyed VSAM file behaves like a lightweight database table. A KSDS (key-sequenced dataset) supports keyed lookup and update; an ESDS (entry-sequenced dataset) is append-only.
GDG — generation data group: numbered versions of a dataset;
(+1)in JCL means "create the next generation".DB2 for z/OS — IBM's relational database on the mainframe; a subsystem is a named DB2 instance, such as
DAZ1above.EXEC SQL — SQL embedded in COBOL source, processed by a precompiler and bound into packages before run time.
DSNTIAUL — IBM's sample utility for unloading DB2 tables to flat files.
TSO — Time Sharing Option: z/OS's command environment, in effect the mainframe's shell; IKJEFT01 is the program that runs TSO commands inside batch jobs.
CICS / IMS — IBM transaction-processing middleware, roughly the mainframe's application servers; IMS also includes a hierarchical database.
DFSORT / IEBGENER / IDCAMS — standard z/OS utilities for sorting, copying, and VSAM/catalog management.
PDS — partitioned dataset: a library whose members act like files in a folder.
Endevor / ChangeMan ZMF / Panvalet — mainframe source-code management systems that predate git.
Control-M / CA-7 / IWS — enterprise job schedulers: the mainframe estate's cron and orchestrator.
AST — abstract syntax tree: the structured, machine-readable representation of source code that a parser produces; the form in which the analyzer reasons about programs.
DDL — Data Definition Language: the subset of SQL that defines tables, schemas, and other database objects (
CREATE TABLE, ...).BCBS 239 — the Basel Committee's principles for risk-data aggregation and risk reporting; the regulatory framework most often driving bank lineage programs.



