What an XER file is

XER is Primavera P6's own export format: a flat, tab-delimited text file holding a dump of database tables. It is not XML, it is not compressed, and it carries no schema. A schedule arrives as rows of a relational export, and whether a reader gets the right dates out of it depends almost entirely on which columns it reads and how it decodes the ones that are packed.

This page describes the file structure as this project's importer reads it. It is deliberately not a field dictionary — Oracle publishes one, and it is the authority. See the Oracle Primavera P6 EPPM XER Import/Export Data Map Guide (Project), version 24, December 2023 for the table and column list. What that guide does not describe is the packed calendar string, and that is the subject of the sibling page, Inside clndr_data.

The header line

The first line is not a table. It begins with the literal token ERMHDR, followed by tab-separated fields. An export written by P6 23.12 looks like this:

ERMHDR  23.12   2026-08-11 12:54    Project ADMIN   danaf   dbxDatabaseNoName   Project Management  CAD

The second field is the version of P6 that wrote the file, and it is the only field this project's importer reads from the header — it is carried through as the source version, because "which tool wrote this" is a fact a reader of a report is entitled to. The remaining fields, as seen in real exports, carry the export timestamp, the export type, the user login and display name, the database name, the module name and the currency. Their authoritative meaning is Oracle's to state, not ours.

Two things the header does not carry are worth naming, because both cost a reader something:

The record grammar

After the header, the file is a sequence of four line markers. Each marker is the first tab-separated field on its line.

Marker Meaning
%T begins a table; the next field is the table name
%F the column names for the table just opened
%R one row; its fields align positionally with the preceding %F
%E end of file

A minimal well-formed fragment:

%T  PROJECT
%F  proj_id proj_short_name plan_start_date
%R  1   NGT-2026    2026-09-07 00:00
%E

Tables appear in whatever order the exporter wrote them, a table may be absent entirely, and a %F line applies only until the next %T. There is no nesting: relationships between tables are by key, exactly as in the database the file was dumped from.

The row, and what makes it harder than it looks

A row is split on tab characters. XER has no quoting and no escape sequence, which produces three consequences a tokenizer has to handle explicitly.

A tab inside a value forges a column. There is no way to distinguish it from a field separator. A row that comes out with more fields than its header has almost certainly suffered this, and the honest response is to say so rather than silently keep the surplus. This importer reports it as XER.ROW.LONG; a row that is short of fields is padded and reported as XER.ROW.SHORT, because losing a whole activity over a trailing empty field is the worse failure.

A newline inside a value splits the row across physical lines. P6 writes notebook topics (TASKMEMO.task_memo) containing literal newlines, so one logical row can span many lines of the file. A line that starts with none of the four markers is therefore a continuation of the row above it, not junk. Reading it as junk truncates the note and discards every field after it.

Splitting on "any line break" is wrong. Real activity names contain vertical tab, form feed, the ASCII file, group and record separators, NEL and the Unicode line and paragraph separators. Every one of those is treated as a line break by the naive splitting routine most languages offer, and treating one as a row terminator invents a row boundary that is not in the file. This importer splits on the newline character alone.

A doubled quote is one quote. Cells are never wrapped in quotes, but P6 doubles an embedded quote character when it writes a text cell — a planner's 1" pipe is stored as 1"" pipe. A cell that is exactly two quote characters holds one quote, not an empty string. Note the distinction from dequoting: a name that genuinely begins and ends with a quote survives undoubling and must not have its outer quotes stripped.

How the tables relate

Six tables carry the schedule itself. Everything else is metadata, codes, costs, notes or definitions.

Table Holds Joins on
PROJECT one row per project: data date, planned start, the Must Finish By deadline, the default calendar proj_id
PROJWBS the work breakdown structure, as a parent-pointer tree wbs_id, parent_wbs_id, proj_id
TASK activities: code, name, type, status, durations in hours, stored dates, constraint task_id, proj_id, wbs_id, clndr_id
TASKPRED the logic: one row per relationship task_id (successor), pred_task_id, pred_type, lag_hr_cnt
CALENDAR working time clndr_id, base_clndr_id
SCHEDOPTIONS the scheduling settings that change the answer proj_id

Four traps in that mapping are worth stating because they change computed dates rather than raising an error:

And one omission is worse than any of them: an importer that skips TASKPRED produces a network with no logic at all, in which every activity reports as critical with zero float and nothing about the output looks broken.

Nothing declares the character encoding

ERMHDR states a version, a date, a user and a currency, and says nothing about how the bytes are encoded. P6 writes the database code page, most often Windows-1252. Anything that has been through a modern editor comes back as UTF-8, sometimes with a byte-order mark, and files routed through some mail systems arrive as UTF-16.

Decoding as UTF-8 raises an exception on the Windows-1252 files, which is the likeliest way a first real file fails: a traceback naming a byte offset, from a file that is not corrupt at all. Decoding with replacement characters is worse in a different direction, because an activity named Béton becomes B?ton with nothing logged, and the identifier a finding is matched on may be the thing that was mangled.

The failure that has no error attached at all is a code page that is not Windows-1252. An export from an Arabic-locale install carries cp1256, and Windows-1252 maps almost every byte of it to some Latin letter — producing plausible-looking noise standing exactly where an activity name should be, with nothing raised. This importer tests the text for whole words written in one script before falling back, and reports which code page it chose, so the choice is visible in the report rather than assumed. The method and its measured false-negative cases are in this repository's encoding notes.

One file, many projects

A single XER can hold several PROJECT rows, and in practice they are often a real schedule paired with one or more near-empty stubs — a one-activity shell, or a project record with no activities at all. Which one is written first is not governed by size.

A reader that silently takes the first project can therefore produce a complete, confident report about a one-activity stub while the several-thousand-activity schedule beside it is never read. This importer enumerates the projects it found and refuses to choose one on your behalf; the project must be named. The same applies to TASK.task_id uniqueness across a multi-project file, which a batch caller has to be able to rely on.

What a reader owes you

Every default taken, value coerced and row dropped in this importer carries a stable code and a stated action, and those reach the report ahead of any finding. A silent default produces a plausible schedule that is wrong, with nothing in the output to look at — which is the whole argument for the disclosure, and the reason the calendar page next door exists at all.

Next: Inside clndr_data — the packed calendar string, which Oracle's data map does not describe. See also How it works and What this does not do.

Source: web/pages/xer-format.md. Source commit date: 2026-09-11.

See it in practice

Follow the evidence, from the schedule to the finding.

Explore the sample