NEW Qlik to dbt migration AI Proof Pricing Book a demo Get the free assessment

Hard sources

Also parsed

Targets

Warehouses

Runtimes

Before migration

After migration

Mainframe to Databricks: Moving COBOL, JCL and Copybook Batch to PySpark and Lakeflow Jobs

Mainframe batch is rarely hard because of COBOL syntax. It is hard because of bytes, arithmetic and condition codes. Here is how each one lands on Databricks, and where a straight rewrite goes wrong.

September 29, 2026 · 13 min read · MigryX Team

Most mainframe estates that come to us have the same shape. There are thousands of COBOL batch programs, hundreds of copybooks, JCL job streams that have run nightly for decades, and DB2 tables at either end. The business logic is sound. What the owners want to escape is the MIPS bill, the shrinking pool of people who can change it safely, and a batch window that no longer fits.

Databricks is a natural landing zone for that batch work. Record-at-a-time COBOL becomes set-based PySpark. Flat files become Delta tables governed in Unity Catalog. JCL becomes Lakeflow Jobs (formerly Databricks Workflows). Getting there safely depends on three layers that generic code translators tend to skip: the bytes, the arithmetic and the control flow.

In this guide
  1. What moves, and what doesn't
  2. A batch job, before and after
  3. The bytes: copybooks, EBCDIC and packed decimal
  4. The arithmetic: COBOL programs to PySpark
  5. The control flow: JCL to Lakeflow Jobs
  6. Artifact mapping
  7. How a mainframe migration runs

What moves, and what doesn't

Be precise about scope from day one. Batch has clear inputs, outputs and schedules, and it maps cleanly onto a lakehouse. Online transaction processing is a different problem with a different answer.

In scope: mainframe batch

  • COBOL batch programs
  • Copybooks and record layouts
  • JCL job streams, PROCs, SORT steps
  • Embedded DB2 SQL
  • Sequential and GDG datasets

Not in this program

  • CICS and other online transactions
  • Green-screen (3270) applications
  • Rehosting COBOL on an emulator

This is a modernization, not a rehost: the output is native PySpark and Delta, not COBOL running on rented hardware. Your team keeps and maintains that code.

A batch job, before and after

Here is a typical monthly job: sort the account master, run the interest program, load the result into DB2. On the mainframe that takes three JCL steps, two datasets and a generation data group (GDG). On Databricks it becomes two tasks, a condition check and three Delta tables.

JCL job ACCTINT mapped to a Lakeflow Job Three JCL steps with their datasets on the left map to two Lakeflow Job tasks, a condition task and three Delta tables in Unity Catalog on the right. JCL JOB ACCTINT LAKEFLOW JOB acct_monthly_interest PROD.ACCT.MASTEREBCDIC, fixed 29 bytes &&SORTEDtemporary dataset ACCT.INTEREST(+1)new GDG generation DB2 ACCT_INTERESTreporting table STEP010 · PGM=SORTSORT by ACCT-ID, INCLUDE type 'S' STEP020 · PGM=ACCTINTCOND=(4,LT) · runs if prior RC ≤ 4 STEP030 · DB2 loadCOND=(0,NE) · runs only if all RC = 0 bronze.acct_masterDelta, decoded from EBCDIC silver.acct_interestDelta, one version per run gold.acct_interestUnity Catalog, BI-ready step020_acctintPySpark · filter and sort folded in rc_checkcondition task · return_code = 0 step030_acctloadruns when rc_check is true → STEP010 disappears: its sort and filter are one line of PySpark.
Figure 1. Temporary datasets vanish, GDG generations become Delta versions, and JCL condition codes become explicit task conditions.

The bytes: copybooks, EBCDIC and packed decimal

A mainframe file has no header, no delimiters and no types. The copybook is its only schema, and it describes bytes, not columns. Before any business logic runs, every record has to be decoded exactly as the COBOL program would read it.

Copybook ACCTREC.cpy
       01  ACCT-REC.
           05  ACCT-ID         PIC X(10).
           05  ACCT-BAL        PIC S9(9)V99  COMP-3.
           05  ACCT-TYPE       PIC X(01).
               88  SAVINGS     VALUE 'S'.
               88  CHECKING    VALUE 'C'.
           05  OPEN-DATE       PIC 9(08).
           05  INT-RATE        PIC S9(2)V9(4) COMP-3.
A 29-byte ACCT-REC record, byte by byte The record splits into ACCT-ID (10 bytes of EBCDIC text), ACCT-BAL (6 bytes of packed decimal), ACCT-TYPE (1 byte), OPEN-DATE (8 zoned digits) and INT-RATE (4 bytes packed). A zoom shows the packed bytes 00 12 34 56 78 9C decoding to +1234567.89. ONE RECORD · 29 BYTES · NO DELIMITERS ACCT-IDPIC X(10) · EBCDIC text · bytes 1–10 ACCT-BALCOMP-3 · bytes 11–16 TYPE OPEN-DATEPIC 9(8) · zoned · bytes 18–25 INT-RATECOMP-3 · 26–29 C1 C3 C3 F0 F0 F0 F1 F2 F3 F4"ACC0001234" E2'S' F2 F0 F2 F6 F0 F9 F2 F820260928 00 42 50 0C+04.2500 ZOOM · ACCT-BAL PIC S9(9)V99 COMP-3 (11 digits + sign = 12 nibbles = 6 bytes) 00 12 34 56 78 9C Digits 00123456789, sign nibble C (positive; D = negative), implied decimal V99 → +1234567.89
Figure 2. If you decode ACCT-BAL as text, or skip the implied decimal, every balance is wrong and nothing errors. The copybook is the only thing that says how to read it.

Four details break most homegrown readers:

On Databricks, the decoded result lands in a Bronze Delta table with real types. One common route is an open-source Spark reader for COBOL data such as Cobrix, which applies the copybook directly:

PySpark · bronze ingest
raw = (spark.read.format("cobol")
         .option("copybook", "/Volumes/mainframe/raw/copybooks/ACCTREC.cpy")
         .option("record_format", "F")
         .option("ebcdic_code_page", "cp037")
         .load("/Volumes/mainframe/raw/landing/ACCT.MASTER.D260928"))

(raw.write.format("delta").mode("overwrite")
    .saveAsTable("mainframe.bronze.acct_master"))

Whichever reader you use, the test is the same: decoded values must match what the COBOL program saw, field by field, including sign and scale, before any logic is converted.

The arithmetic: COBOL programs to PySpark

A COBOL batch program reads a record, applies rules and writes a record, inside a PERFORM UNTIL loop. Rewritten as a DataFrame, the same rules run across the whole file in parallel, and that is where batch windows shrink. The rules themselves need care, because COBOL arithmetic has its own conventions.

COBOL · ACCTINT (procedure division, trimmed)
       01  WS-INTEREST     PIC S9(7)V99 COMP-3.
       ...
           PERFORM UNTIL END-OF-FILE
               READ ACCT-FILE INTO ACCT-REC
                   AT END SET END-OF-FILE TO TRUE
               END-READ
               IF NOT END-OF-FILE AND SAVINGS
                   COMPUTE WS-INTEREST = ACCT-BAL * INT-RATE / 1200
                   WRITE INT-REC FROM WS-INT-REC
               END-IF
           END-PERFORM.
PySpark · step020_acctint
from pyspark.sql import functions as F

accts = spark.table("mainframe.bronze.acct_master")

raw = F.col("acct_bal") * F.col("int_rate") / F.lit(1200)
# COBOL COMPUTE without ROUNDED truncates toward zero to the receiving scale
truncated = F.when(raw >= 0, F.floor(raw * 100)).otherwise(F.ceil(raw * 100)) / 100

interest = (accts
    .filter(F.col("acct_type") == "S")                     # 88 SAVINGS
    .withColumn("ws_interest", truncated.cast("decimal(9,2)"))
    .select("acct_id", "acct_bal", "int_rate", "ws_interest"))

interest.write.format("delta").mode("overwrite").saveAsTable("finance.silver.acct_interest")
The subtle part: COMPUTE without ROUNDED truncates. Spark's decimal cast rounds. On a million accounts, that one-cent difference per row is a reconciliation failure the day after cutover. Overflow behaves differently too. If the result exceeds S9(7)V99, COBOL silently drops the high-order digits unless the program coded ON SIZE ERROR. Spark returns NULL or raises an error, depending on ANSI mode. Neither matches COBOL by default, so each case should be flagged and decided on purpose, not inherited by accident. Also note that the JCL SORT step is gone: the filter covers its INCLUDE, and the order no longer matters because nothing downstream depends on it.

Other COBOL patterns follow the same principle of keeping the result and changing the mechanics:

The control flow: JCL to Lakeflow Jobs

JCL defines order, datasets and conditions. Its COND parameter is famously back to front: COND=(4,LT) means skip this step if 4 is less than any earlier return code. In other words, it runs only when every prior step ended with RC ≤ 4. Get the logic backwards and a failed step quietly lets the load run.

JCL · ACCTINT
//ACCTINT  JOB (ACCT),'MONTHLY INTEREST',CLASS=A
//STEP010  EXEC PGM=SORT
//SORTIN   DD DSN=PROD.ACCT.MASTER,DISP=SHR
//SORTOUT  DD DSN=&&SORTED,DISP=(NEW,PASS)
//SYSIN    DD *
  SORT FIELDS=(1,10,CH,A)
  INCLUDE COND=(17,1,CH,EQ,C'S')
/*
//STEP020  EXEC PGM=ACCTINT,COND=(4,LT)
//ACCTIN   DD DSN=&&SORTED,DISP=(OLD,DELETE)
//INTOUT   DD DSN=PROD.ACCT.INTEREST(+1),DISP=(NEW,CATLG)
//STEP030  EXEC PGM=IKJEFT01,COND=(0,NE)
//SYSTSIN  DD *
  DSN SYSTEM(DB2P)
  RUN PROGRAM(ACCTLOAD) PLAN(ACCTPLN)
/*
Databricks Asset Bundle · resources/acct_monthly_interest.yml
resources:
  jobs:
    acct_monthly_interest:
      name: acct_monthly_interest
      tasks:
        - task_key: step020_acctint
          notebook_task:
            notebook_path: ../src/acctint.py

        - task_key: rc_check
          depends_on:
            - task_key: step020_acctint
          condition_task:
            op: EQUAL_TO
            left: "{{tasks.step020_acctint.values.return_code}}"
            right: "0"

        - task_key: step030_acctload
          depends_on:
            - task_key: rc_check
              outcome: "true"
          notebook_task:
            notebook_path: ../src/acctload.py

The converted program publishes its return code with dbutils.jobs.taskValues.set("return_code", rc), so warning-level outcomes (RC 4) are handled explicitly rather than lost. Other JCL constructs map just as directly:

Artifact mapping

Mainframe artifactOn DatabricksWatch for
CopybookSpark schema, Bronze Delta tableCode page, COMP-3, zoned signs, REDEFINES, ODO
COBOL batch programPySpark notebook or job taskTruncation vs rounding, SIZE ERROR, intermediate precision
88-level conditionsF.when() predicatesMultiple values and THRU ranges
Control breaksgroupBy / window functionsKey ordering, last-group flush
Embedded DB2 SQLSpark SQL on DeltaCursor loops, NULL indicators, DB2 date types
JCL steps and CONDLakeflow Jobs tasks, condition tasksInverted COND logic, RC 4 warnings
GDG datasetsDelta versions / time travelRetention limits vs GDG LIMIT
DFSORT / ICETOOLorderBy, filter, dropDuplicatesDuplicate survivorship without EQUALS
Scheduler (Control-M, CA-7)Job schedules and triggersCalendars, cross-job dependencies

How a mainframe migration runs

Mainframe batch modernization phases Five phases: assess the estate, pilot two to three job streams, run in parallel with parity checks, migrate in waves, and switch off mainframe batch. AssessJCL, programs,copybooks, call graph Pilot2–3 job streams8–10 weeks Parallel runmainframe vs Delta,field-by-field parity Wavesgrouped by shareddatasets and schedules Switch offbatch MIPS retired,evidence retained Each wave cuts over only when its parity report is clean.
Figure 3. Start narrow, prove parity on real production output, then scale by waves of related job streams.

Start with two or three job streams that share datasets. That is enough to exercise copybook decoding, program logic, DB2 access and JCL control flow together, and small enough to finish in a quarter. During the parallel run, both platforms process the same inputs and the outputs are compared record by record and field by field: sign, scale, dates and all. Cutover happens when that comparison is clean, not when the code compiles.

The proof is the product. Operations and audit teams do not sign off on converted code. They sign off on evidence that the output matches. Plan the parity report as a deliverable from the first week.

Key takeaways

See one of your job streams on Databricks

Bring a JCL job with its COBOL programs and copybooks. We'll walk through the converted PySpark and Lakeflow Job, and how the output is checked against your mainframe run.

Book a demo   COBOL to Databricks