FCS: Flow Cytometry
A flow cytometer measures light scatter and fluorescence on every cell that passes the laser, and it writes one FCS file per sample. FCS, the Flow Cytometry Standard, is the one format every cytometer exports and every analysis tool reads. This page opens one up. For the analysis that follows, compensation, transformation, gating, see the flow cytometry section.
The binary layout
Section titled “The binary layout”An FCS file has four segments. The HEADER is 58 bytes of ASCII at the start of the file, and it holds the version and the byte offsets of the other segments. The TEXT segment is a list of keyword-value pairs describing the run. The DATA segment is the event matrix, one row per cell, one column per detector. An optional ANALYSIS segment follows, rarely used.
The example below reads a small FCS file that ships with the flowCore package, written by a FACSCalibur in 2006. The header bytes are plain ASCII, so you can read them directly.
library(flowCore)
fcs_file <- system.file("extdata", "0877408774.B08", package = "flowCore")file.size(fcs_file)
# The first 58 bytes are the ASCII HEADER segment.con <- file(fcs_file, "rb")header_text <- readChar(con, nchars = 58)close(con)cat(header_text, "\n")[1] 162305FCS2.0 256 2253 2304 162303 0 0The header says the file is FCS version 2.0, the TEXT segment runs from byte 256
to 2253, and the DATA segment from byte 2304 to the end of the file at 162303.
In FCS 2.0 these offsets live only in the header. FCS 3.0 repeated them in the
$BEGINDATA and $ENDDATA keywords, because a large file can push an offset
past the eight characters the header field allows.
The keyword segment
Section titled “The keyword segment”The TEXT segment carries the metadata as keyword-value pairs. Keywords starting
with $ belong to the standard, and the rest are vendor-specific. The ones that
matter most describe the events, the encoding and the instrument.
frame <- read.FCS(fcs_file, transformation = FALSE)keyword(frame, c("$TOT", "$PAR", "$BYTEORD", "$DATATYPE", "$MODE", "$CYT"))$`$TOT`[1] "10000"
$`$PAR`[1] "8"
$`$BYTEORD`[1] "4,3,2,1"
$`$DATATYPE`[1] "I"
$`$MODE`[1] "L"
$`$CYT`[1] "FACSCalibur"This file holds 10,000 events across 8 parameters. $BYTEORD is 4,3,2,1, so
the DATA segment is big-endian. $DATATYPE is I for integer; modern digital
cytometers write F for 32-bit float. $MODE is L for list mode, meaning
every event is recorded. Histogram mode, which bins events by channel value,
exists but is essentially extinct.
One more keyword deserves attention even though this old file lacks it. The
spillover matrix travels in the file under SPILL on most modern instruments,
and under $SPILLOVER on older ones. The compensation
page reads it from there, and a frame that
has lost its keywords has lost the matrix with them.
The parameter table
Section titled “The parameter table”The $P1N, $P2N and friends keywords name each column of the event matrix.
flowCore collects them into the parameter table, one row per detector: the
channel name, the marker description, and the range.
pData(parameters(frame))[, c("name", "desc", "range")] name desc range$P1 FSC-H FSC-H 1024$P2 SSC-H SSC-H 1024$P3 FL1-H <NA> 1024$P4 FL2-H <NA> 1024$P5 FL3-H <NA> 1024$P6 FL1-A <NA> 1024$P7 FL4-H <NA> 1024$P8 Time Time (51.20 sec.) 1024The channels are the classic FACSCalibur set: forward and side scatter, four fluorescence detectors in height and area variants, and time. The range of 1024 betrays the instrument’s age. Analog cytometers digitise to 10 bits, while a modern instrument writes floats across millions of channels.
The event matrix, and a round trip
Section titled “The event matrix, and a round trip”exprs() returns the DATA segment as a numeric matrix. To show the write side,
the code below simulates 4,000 events across four channels, writes a new FCS
file, and reads it back.
set.seed(20260823)n_events <- 4000sim_matrix <- matrix(rnorm(n_events * 4, mean = 500, sd = 150), ncol = 4)colnames(sim_matrix) <- c("FSC-A", "SSC-A", "CD3-PE", "CD19-FITC")
dir.create("outputs", showWarnings = FALSE, recursive = TRUE)write.FCS(flowFrame(sim_matrix), filename = "outputs/simulated.fcs")
reread <- read.FCS("outputs/simulated.fcs")dim(exprs(reread))keyword(reread, "$DATATYPE")
con <- file("outputs/simulated.fcs", "rb")new_header <- readChar(con, nchars = 58)close(con)cat(new_header, "\n")[1] "outputs/simulated.fcs"[1] 4000 4$`$DATATYPE`[1] "F"
FCS3.0 58 722 723 64722 0 0write.FCS writes FCS 3.0, and the round trip preserves the 4,000 by 4 event
matrix. The new file carries $DATATYPE set to F: the simulated values are
floats, so the writer chose the float encoding.
In practice
Section titled “In practice”One FCS file is one sample, and a real experiment produces dozens. The file itself tells you the instrument, the date, the detector setup and the spillover matrix, but not the condition, the donor or the panel. That metadata arrives separately, usually in a spreadsheet, and joining it to the files is the first analysis step. The FCS I/O page in the flow cytometry section builds that table and reads files as a set.
Summary
Section titled “Summary”- FCS is the universal cytometry format: one file per sample, four segments, binary.
- The 58-byte ASCII HEADER holds the version and the segment offsets. FCS 3.0
repeats the DATA offsets in the
$BEGINDATAand$ENDDATAkeywords. - The TEXT segment holds the metadata as keywords.
$TOTand$PARgive the shape,$DATATYPEthe encoding, andSPILLcarries the spillover matrix. - The DATA segment is the event matrix, one row per cell and one column per detector.
- flowCore reads the format with
read.FCSand writes it withwrite.FCS.