Skip to content

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.

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] 162305
FCS2.0 256 2253 2304 162303 0 0

The 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 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 $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.) 1024

The 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.

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 <- 4000
sim_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 0

write.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.

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.

  • 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 $BEGINDATA and $ENDDATA keywords.
  • The TEXT segment holds the metadata as keywords. $TOT and $PAR give the shape, $DATATYPE the encoding, and SPILL carries 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.FCS and writes it with write.FCS.