Build Flows

Scheduling & P6 · October 10, 2026 · 12 min read

Verifying Primavera P6 XER Exports Before They Leave

How we write P6 19.12 .xer files and check them before a scheduler downloads them, and why local verification is kept separate from acceptance by the downstream importer.

By Charley Forey, founder of Build Flows

A schedule that cannot leave the system it was built in is a demo. For Connect, the way out is a Primavera P6 .xer file: the scheduler downloads it, imports it into Syncify or P6, recalculates, and carries on in the tools the firm already uses. That makes the exporter one of the most consequential pieces of code in the product. If it writes a file the downstream importer rejects, the scheduler loses an afternoon. If it writes a file that imports but quietly reads differently, nobody notices until the dates are wrong in front of an owner.

This article covers how we emit P6 19.12 files and how we check them before they leave: a structural re-read, a comparison against the schedule's original inputs, a TypeScript port of the downstream importer's rules, independent readers, and a separate round-trip comparison for files that come back from a real import. It also covers what none of that proves. The product view is in how the Schedule Builder works, and the calculation the file carries is in our CPM engine article. Both are part of our Connect architecture series.

What an XER file actually is

XER is P6's interchange format: one tab-delimited text file that holds many relational tables. It is simple to write and easy to get subtly wrong. The grammar has five record types:

RecordMeaning
ERMHDRHeader line: format version, export date, a few labels
%TStart of a table, followed by its name
%FThe field names for that table, in order
%ROne row, with cells in the same order as %F
%EEnd of file

A minimal illustrative fragment (tabs shown as spaces):

ERMHDR  19.12  2026-10-10  Project  ...
%T  TASK
%F  task_id  proj_id  wbs_id  task_code  task_name  target_drtn_hr_cnt
%R  1000     1        100     A1000      Excavate   40
%R  1001     1        100     A1010      Footings   24
%T  TASKPRED
%F  task_pred_id  task_id  pred_task_id  pred_type  lag_hr_cnt
%R  1             1001     1000          PR_FS      0
%E

Three properties cause most of the trouble. Keys are integers, so string IDs have to be mapped. Durations and lags are stored in hours, so everything depends on hours per day. And there is no escaping: a stray tab in an activity name shifts every later cell one column right, which is how a finish date ends up in a float column.

Emission: deciding what the importer will believe

The emitter writes CURRTYPE, OBS, CALENDAR, PROJECT, SCHEDOPTIONS, PROJWBS, TASK and TASKPRED, under a 19.12 header, which is the P6 release the operator's databases run and the first thing an importer checks. The decisions that matter:

  • Stable integer IDs and deterministic GUIDs. GUIDs are derived from a hash of each record's own ID, so two exports of the same schedule diff cleanly.
  • Hours at eight per day. Durations and lags are written as working days times eight. Dates are YYYY-MM-DD HH:MM, with starts at 08:00 and finishes at 17:00.
  • A real status date, always. The downstream importer treats last_recalc_date as the schedule's data date and silently skips an empty cell. An empty cell would not fail; it would leave the data date wrong. So it is always populated.
  • Foreign keys that resolve. Every PROJWBS row points at an OBS node. One enterprise root OBS row satisfies that, at the cost of a table the target may never read.
  • Every calendar, with its holidays. Each calendar an activity runs on is written under the operator's P6 calendar names, so an import maps it onto the existing global calendar. Holidays become P6 calendar exceptions inside the clndr_data blob, so a recalculation lands on the same working days our CPM did. Each activity carries its own clndr_id.
  • Scheduling options that reproduce the dates. A SCHEDOPTIONS row states how the dates were calculated: lags on the predecessor's calendar, total float measured as finish float, retained logic. Without it, pressing F9 in P6 recalculates under whatever defaults that database has.
  • Milestone semantics. A milestone that something finishes into is written as a finish milestone, using the same rule the CPM engine uses to date it.

Then there are the defensive rules. Cells collapse tabs and newlines to a space. Name columns (task_name, wbs_name) double any quote character, because the importer un-doubles quotes on exactly those two columns and reads codes verbatim. And every date is validated as a real calendar date before it is written: 2026-02-30 matches a shape regex, but a strict parser downstream rejects the whole file, not just the row.

Verification layer one: re-read the file as an adversary

The verifier never trusts the serializer. It parses the emitted text from scratch and checks it in stages:

  1. Required tables are present.
  2. Every %R row has exactly as many cells as its %F header. This is the column-shift check, and if it fails, the verifier stops there, because every later check would only add noise.
  3. Every *_date cell is YYYY-MM-DD HH:MM, or empty only in the few columns P6 legitimately leaves blank, such as late dates or constraint dates.
  4. Non-milestones have a positive duration; milestones have zero.
  5. WBS parents, activity WBS assignments and relationship endpoints all resolve.
  6. The project's planned start is not after its finish. There must be exactly one default calendar, positive calendar hour fields and a non-empty project short name, which the importer uses as the schedule code.
  7. Our own importer reads the file back to the expected counts of activities, relationships and WBS nodes.

Layer two: compare against the original inputs

A file can be perfectly well formed and still say the wrong thing. So when the original schedule content is available, which it is on every export, approval and evaluation, the verifier recalculates it with the CPM engine and compares the file against that result:

  • Every activity's early, late and target dates and times, and its total and free float in hours.
  • The project's start, finish and data date.
  • Full WBS paths (code and name of every ancestor) and each activity's placement, not just node counts.
  • Every relationship as a tuple of predecessor code, successor code, type and lag.
  • Durations, milestone flags, constraint types and dates, and each activity's calendar.
  • Each calendar's workweek, shift hours and holiday exceptions inside the schedule window.

Generated IDs and row order may differ; meaning may not. One rule here is about honesty rather than format. The forecast engine schedules whole days, so a fractional-day duration or lag fails verification with a message to review it, rather than being rounded into a file whose dates it no longer matches.

Layer three: a port of the downstream importer

Layers one and two share a blind spot: our own importer reads files the way our own emitter writes them. If both get quotes wrong in the same way, both agree, and the bug is invisible.

So we wrote a self-contained model of how the downstream importer reads a .xer, transcribed from its Go source rather than inferred from observation. It reproduces three kinds of outcome:

OutcomeExampleEffect
RejectHeader export date that is not a bare, valid date; an integer column that does not parseThe whole import aborts
DropA column the importer does not know; a task whose project ID does not resolveData silently disappears
MisreadQuote handling on name columns; a cell that reads as the literal string "null"A value is silently changed

It includes the importer's column-type map for every table we write, its date and duration parsing (including the integer-minute ranges durations are stored in), and a verbatim port of its calendar parser, which gives us a second, independent reader of clndr_data. The verifier runs it as a final stage: every %F field must be a known column of its table, numeric cells must be bare (no commas, exponents, NaN or Infinity), the importer's calendar parser must read back each calendar's workweek, and every activity and WBS name and code must survive import unchanged.

A property test drives this with hundreds of seeded random schedules built from an adversarial alphabet: tabs, newlines, lone and doubled quotes, non-ASCII text, the literal word "null", padding and over-length strings. For each seed, our verifier must pass, the importer model must not reject, names and codes must decode to the originals, and counts must match. The important property is that the first implies the second. A seed our verifier accepts but the importer model rejects is a new gap in our verifier, and the test says so.

Independent readers outside the test suite

Two optional checks read our emitted bytes with MPXJ, a widely used open-source library for project files: one exercises its calendar parser across five-, six- and seven-day weeks, and one reads complete files across a matrix of calendars and relationship types, asserting names, durations, WBS paths, constraints, signed lags and day-by-day working time. They run by hand in a pinned, network-isolated container and add no Java dependency to the product. They show how a strict third-party reader parses the bytes; they recalculate nothing.

Round-trip comparison: when a file comes back

The real question is what happens after an import and recalculation in the target system. That cannot be answered inside Connect, so we built a separate command for evidence gathered outside it. A person imports the exported file, recalculates, exports again, and runs:

check:xer-round-trip --source original.xer --returned recalculated.xer
                     --observation observation.json --out NEW_DIRECTORY

The comparison deliberately uses a raw reader, not Connect's importer. Our importer simplifies (it collapses calendars, drops external links, ignores actuals), and using it here would hide exactly the losses under test. The raw reader enforces the grammar strictly (header first, %E last, no duplicate tables or fields, row width equals header width) and builds a snapshot keyed by meaning rather than database ID:

  • Activities by code, with names, types, durations, all date fields including actuals, floats, both constraints, WBS path and fully resolved calendar, inheritance included.
  • WBS nodes by their code path.
  • Relationships by predecessor, successor, type and lag, with external endpoints resolved through their project's short name.

IDs, export order and equivalent spellings of numbers are normalized. Time of day is not discarded, and calendar payloads are compared exactly. An unfamiliar serialization is flagged for review rather than assumed equivalent. Fields and tables the comparison does not examine are listed by name, so silence never reads as coverage.

The observation file has a strict schema: who ran the import, the Connect commit and schedule revision, the target application and version, ordered past timestamps for import, recalculation and export, and the scheduling settings used. The command writes both files, the observation and the comparison into a new directory it refuses to overwrite, records SHA-256 hashes, and labels the observation "operator-reported, not independently verified". Exit codes separate matched, different and invalid evidence.

Every comparison carries its own list of limitations, starting with this one: a match is a local file comparison, and import and recalculation must be observed and recorded independently.

Approval freezes the bytes

Verification is a hard precondition of approval. Approval reruns CPM, generates the file, verifies it against the original inputs, and only then stores an immutable approval record containing the exact XER bytes along with the calculation version and the dated output. Exporting an approved revision serves those stored bytes rather than regenerating them, so what the reviewer approved is what leaves.

The stored file is still checked against today's format rules on every export. If the rules have tightened since approval and the old file fails, the export is refused and the original record is preserved untouched. The fix is a new draft and a new review, never a silent regeneration under an old signature.

The import side: refuse before saving, keep human decisions

Revised schedules come back too: a scheduler adjusts the baseline in P6 and drops the new .xer on Connect. The importer reads fields by their %F names, never by position, because real exports carry extra tables and columns in any order. It converts hours to days using each activity's own calendar day length, and lags using the predecessor's calendar.

Calendars get the strictest treatment. The importer follows each selected calendar's base-calendar chain, keeping its own workweek and merging inherited holidays. A missing base calendar, a duplicate calendar ID, a cycle, or a date-specific partial working shift the model cannot represent all reject the file before anything is persisted. Our Playwright suite exercises exactly this: it uploads a file with a partial working shift on a holiday, then one with a missing base calendar, and asserts that each shows a clear error, that the upload control recovers, and that the schedule's version list is unchanged afterwards, at phone and desktop widths in both themes.

What the format cannot carry is kept from the prior revision instead of being lost. XER has no place for the human decisions on conflicting sources, required milestones, requirement inventories or activity evidence. An import carries them forward from the prior revision, re-attaching activity evidence by activity code and clearing any link whose activity no longer exists, and says plainly that they were retained but not re-reviewed against the imported changes. Lossy simplifications, such as progress being imported as baseline inputs or a secondary constraint being dropped, come back as warnings rather than being hidden.

What local verification does and does not establish

CheckEstablishesDoes not establish
Structural re-readThe file is well formed and internally consistentThat any importer reads it the same way
Original-input comparisonThe file says what Connect calculatedThat Connect's calculation matches another engine
Importer portThe bytes pass a faithful model of the importer's rulesAcceptance by the live importer
Independent readersA strict third-party parser reads the bytes as intendedRecalculated dates
Round-trip comparisonTwo files agree on the compared fieldsThat the import was performed as reported, or that nothing was omitted from both

Acceptance by the downstream system is a separate, observed step with its own record. We keep that line visible in the code, in the comparison output and in the product copy.

Why this matters if you're building something similar

  • Verify the bytes, not the object. Re-parse what you emitted with a reader that shares no code with the writer.
  • Model the consumer's reader. Your own round trip cannot catch a mistake your reader and writer share. Port the target's reject, drop and misread rules from its source when you can.
  • Fuzz with hostile strings. Delimiters, quotes and sentinel words like "null" find bugs that realistic fixtures never will.
  • Compare against the inputs, not just the file. A parseable file can still have dropped a relationship.
  • Freeze what was approved. Store exact output bytes with the approval, and refuse rather than regenerate when old output fails new rules.
  • Write down the limits. Put the "this does not prove" list in the output itself, where a reader cannot miss it.

Where to go next

If your product has to hand files to a system you do not control, talk to us about your build.

Frequently asked questions

What is the structure of a Primavera P6 XER file?

An XER file is tab-delimited text. It starts with an ERMHDR header line, then holds table blocks: %T names a table, %F lists its field names, and each %R line is one row with cells in field order. The file ends with %E. Durations and lags are stored in hours and dates as YYYY-MM-DD HH:MM.

Why is re-parsing your own XER output not enough?

If your importer and emitter share an assumption, for example about quotes or calendar grammar, a round trip through your own code will agree with itself and hide the bug. Connect also runs a model of the downstream importer's rules, transcribed from its source, plus hundreds of fuzzed schedules built from hostile strings.

Does passing verification mean P6 or Syncify will recalculate the same dates?

No. Local verification shows the file is well formed, matches Connect's calculation and passes a model of the importer's rules. Import and recalculation in the target system is a separate step that has to be observed and recorded, which is what the round-trip comparison tool is for.

How are holidays and calendars carried in an XER export?

Each calendar is a CALENDAR row whose clndr_data field holds a structured record of the working week and shifts. Connect writes every calendar an activity uses, under the operator's P6 calendar names, with holidays as calendar exceptions, so a recalculation lands on the same working days.

What happens when a revised XER is imported back into Connect?

Fields are read by name, not position. Calendar problems the model cannot represent, such as a missing base calendar or a partial working shift on a specific date, reject the file before anything is saved. Human decisions and evidence that XER cannot carry are kept from the prior revision, with a warning that they were not re-reviewed.

Next step

Thinking about a scheduling pilot?

Start with one exported schedule and an agreed set of checks. We'll discuss your format, method, and approval process.

Prefer email? charley@buildflows.ai

Get the next guide in your inbox

Field Notes: practical guides and new walkthroughs, about once a month.

Field Notes

Practical guides and new walkthroughs on construction data and automation, roughly monthly.

Keep learning