Inside an FDA Submission: Reading SAS Transport (.xpt) Files
Open a clinical study’s submission package and the data is all .xpt: dm.xpt, ae.xpt, adsl.xpt, one file per domain. They are SAS transport files, and the people who need to look inside them — reviewers, data managers, medical writers, clinical scientists — often have no SAS seat to do it with.
Why Everything Is .xpt
SAS transport (XPORT) is SAS’s interchange format. Unlike a .sas7bdat, its layout is published, which is why regulators chose it: the FDA requires study data sets — SDTM tabulations and ADaM analysis data — to be submitted as SAS XPORT version 5 files. Version 5 is old and strict: variable names of at most 8 characters, labels of at most 40, and character values of at most 200 bytes. That is why SDTM names look like USUBJID and AESTDTC.
| Approach | What it costs |
|---|---|
| SAS | The native route, and a licence per seat. |
R (haven::read_xpt) or Python (pandas or pyreadstat) | Reliable, once installed and set up — which a validated or locked-down workstation may not allow. |
| An online viewer | Patient-level trial data sent to an unknown server. For unblinded data, not an option at all. |
Doing It in the Browser
OmniSelect FileSQL reads .xpt files with ReadStat, the library behind R’s haven and Python’s pyreadstat, compiled to WebAssembly and run inside your browser tab. The files are never uploaded.
- Open the app.
- Drop
dm.xptandae.xptonto the File Select panel. Each gets a row, type XPT, and a table name in the Alias column from the first letter of its file name:Dfor DM,Afor AE. - Look at the demographics.
SELECT * FROM D LIMIT 100
| studyid | domain | usubjid | siteid | age | sex | armcd | arm | rfstdtc |
|---|---|---|---|---|---|---|---|---|
| CV-301 | DM | CV-301-0101 | 01 | 54 | F | PBO | Placebo | 2026-01-12 |
| CV-301 | DM | CV-301-0102 | 01 | 61 | M | TRT | Drug 10 mg | 2026-01-14 |
| CV-301 | DM | CV-301-0103 | 01 | 47 | F | TRT | Drug 10 mg | 2026-01-15 |
| CV-301 | DM | CV-301-0104 | 01 | 69 | M | PBO | Placebo | 2026-01-19 |
Column names are shown in lower case. SQL does not mind: USUBJID and usubjid are the same column, so write whichever your eyes are used to.
Quick Questions in Plain English
The plain-English box turns a question into SQL inside your browser, with no AI service. It works best on one domain at a time, naming its variables:
| You type | It writes |
|---|---|
| how many subjects per arm | SELECT arm, COUNT(*) AS count_rows FROM D GROUP BY arm |
| average age by sex | SELECT sex, AVG(age) AS avg_age FROM D GROUP BY sex |
| count by aesev | SELECT aesev, COUNT(*) AS count_rows FROM A GROUP BY aesev |
| aedecod where aeser is Y | SELECT aedecod FROM A WHERE aeser = 'Y' |
A question that crosses domains is a join, and it is clearer written as SQL. Ask for count of adverse events by arm and you get a count of DM rows per arm, because adverse events is not a variable name. So always read the SQL before you trust the number — it is right there, beside the question.
Across Domains, in SQL
AE and DM share USUBJID, so a join puts each event next to the subject’s arm. Events and subjects with an event, per arm:
SELECT D.ARM, COUNT(*) AS events, COUNT(DISTINCT A.USUBJID) AS subjects_with_ae FROM A JOIN D ON A.USUBJID = D.USUBJID GROUP BY D.ARM ORDER BY D.ARM
| arm | events | subjects_with_ae |
|---|---|---|
| Drug 10 mg | 7 | 4 |
| Placebo | 2 | 2 |
Severity by arm is one more column in the GROUP BY:
SELECT D.ARM, A.AESEV, COUNT(*) AS events FROM A JOIN D ON A.USUBJID = D.USUBJID GROUP BY D.ARM, A.AESEV ORDER BY D.ARM, A.AESEV
| arm | aesev | events |
|---|---|---|
| Drug 10 mg | MILD | 4 |
| Drug 10 mg | MODERATE | 2 |
| Drug 10 mg | SEVERE | 1 |
| Placebo | MILD | 2 |
A listing of serious events with each subject’s demographics:
SELECT A.USUBJID, D.ARM, D.AGE, D.SEX, A.AEDECOD, A.AESEV FROM A JOIN D ON A.USUBJID = D.USUBJID WHERE A.AESER = 'Y'
| usubjid | arm | age | sex | aedecod | aesev |
|---|---|---|---|---|---|
| CV-301-0203 | Drug 10 mg | 66 | F | Nausea | SEVERE |
And the subjects with no adverse event at all — a LEFT JOIN that keeps DM rows with no match in AE:
SELECT D.USUBJID, D.ARM FROM D LEFT JOIN A ON A.USUBJID = D.USUBJID WHERE A.USUBJID IS NULL
| usubjid | arm |
|---|---|
| CV-301-0101 | Placebo |
| CV-301-0202 | Placebo |
Drop your .xpt files in and query them together.
Open the app →A Whole Folder at Once
Submission data usually travels zipped. Add the .zip itself and every .xpt inside becomes its own table, shown with its path — sdtm.zip › m5/datasets/cv-301/tabulations/sdtm/dm.xpt — and ready to join. Nothing is extracted to your disk.
Two things to watch. Table names are single letters: when two files start with the same letter, the second takes the first free one, so after ae.xpt has A, adsl.xpt becomes B. Read the Alias column, or type a letter you prefer into it. And there are 26 letters: a package with more domains than that adds the first 26 and says which were left out, so remove the ones you do not need and add the rest.
Dates in SDTM and ADaM
The two standards store dates differently, and both come through as they should:
- SDTM
--DTCvariables are ISO 8601 text, and stay text — including partial dates such as2026-01. Because ISO dates sort in date order, text comparisons work:WHERE rfstdtc >= '2026-01-20'returns the subjects who started on or after 20 January. - ADaM numeric dates, such as
TRTSDTwith aDATE9.format, are SAS day counts in the file and arrive as real dates:2026-01-12.
A demographics summary by arm, for a quick look at balance:
SELECT arm, COUNT(*) AS subjects, ROUND(AVG(age), 1) AS mean_age,
MIN(age) AS min_age, MAX(age) AS max_age
FROM D
GROUP BY arm
| arm | subjects | mean_age | min_age | max_age |
|---|---|---|---|---|
| Placebo | 4 | 54.3 | 43 | 69 |
| Drug 10 mg | 4 | 58 | 47 | 66 |
What Comes Across
| In the .xpt file | In the table |
|---|---|
| XPORT version 5 (the FDA format) and version 8 | Both open |
| Character variables | Text, including ISO 8601 dates |
| Numeric variables | Numbers |
| Numeric variables with a SAS date, datetime or time format | Dates, date-times and times |
| Missing values | Empty — NULL in SQL |
| Data set and variable labels | Not shown: columns are named by the variable names |
Any result exports to CSV, Excel, JSON or Parquet from the Export menu — a serious-event listing as a spreadsheet for a safety review, say. The export holds every row of the result.
Reasonable Questions
Is this a validated system?
No. It is for looking, checking and answering questions quickly. It does not replace the validated programs that produce submission data or its tables and listings, and results you report formally should come from those.
Is it safe for unblinded or patient-level data?
The files are read inside your browser and are not uploaded anywhere; after the page has loaded it makes no network requests at all. You can check that yourself in about a minute, and your organisation’s own rules still decide which machines may hold the data.
Can it write .xpt files?
No. Exports are CSV, JSON, Excel or Parquet.
How large a data set can it open?
Up to 50 MB and 1,000,000 rows per file. Large findings domains such as LB can exceed that in big studies; filter them down in SAS, R or Python first.
Does it read .sas7bdat too?
Yes — see opening a .sas7bdat file without SAS. Both kinds can be loaded and joined together.