Getting started
Install edf2csv, convert your first recording, and understand each of the files it writes
What edf2csv is
edf2csv is a command-line tool that reads an EDF, EDF+ or BDF/BDF+ biosignal recording — EEG, sleep, ECG, EMG — and writes it out as CSV. Alongside the data it writes a channel table, the EDF+ events, and a metadata file describing what was converted.
It runs entirely on your own machine, it doesn't alter the recorded values, and it never resamples a channel to make the output table tidier.
Requirements
Node 20 or newer, and nothing else. edf2csv installs no dependencies at all, makes no network calls, and is MIT licensed. To check what you have:
node --version
Running it
The quickest route is npx, which fetches the tool on demand and leaves nothing installed:
npx edf2csv recording.edf
If you convert files regularly, install it once:
npm install -g edf2csv
After a global install the command is just edf2csv. The rest of this page uses that form; add npx in front of every command if you skipped the install.
Your first conversion
Point it at a file. No flags are required.
edf2csv recording.edf
For a small EDF+ file holding one 100 Hz EEG channel and three events, the output is:
Wrote recording_csv
signals.csv 300 rows
annotations.csv 3 rows
channels.csv 1 rows
Done in 0.0s.
Some notes on that:
- The output directory defaults to the input filename with
_csvappended, created next to the input file. Use-oor--outto put it somewhere else. - If that directory already exists, edf2csv leaves it alone and exits with status 1. Pass
--forceto overwrite it, or--outto write elsewhere, so a new conversion never mixes into an old one. - The summary, any warnings, and the live
converting… 42%progress line all go to stderr. Only--infoand--jsonwrite to stdout, so you can pipe results straight into another program. - Exit status is 0 on success, 1 when a file couldn't be read or written, and 2 when the command itself was wrong — an unknown flag, or a channel name that doesn't exist.
--quietsuppresses the summary. Warnings and errors still print.
Conversion is streamed rather than loaded into memory. A 40 MB EDF that expands into a 159 MB CSV converts in roughly 1.4 seconds with the Node heap capped at 48 MB, so file size affects disk space rather than memory.
What is in the output directory
recording_csv/
signals.csv the data: one row per sample time, one column per channel
channels.csv one row per channel in the recording, with its calibration
annotations.csv EDF+ events, written only when the file has an annotation channel
metadata.json what was read, what was written, and every warning raised
signals.csv
The first column is time_s, seconds elapsed from the start of the recording. Every other column is a channel, named with the label exactly as the file stores it, spaces and punctuation included.
time_s,EEG Fpz-Cz
0.000,0.061
0.010,15.324
0.020,30.464
The number of decimals in time_s is chosen so the sample interval is written exactly rather than rounded. At 100 Hz that's three places, as above. At 256 Hz it's eight, so a row reads 0.00390625 and multiplying time_s by the rate gives back a whole number instead of something like 8191.999999.
If two channels in the file share a label, both column names get a _ch suffix carrying the channel's position — T8-P8_ch0, T8-P8_ch1 — since position is the only thing that reliably tells them apart.
If the recording mixes sampling rates, there's no single signals.csv. You get signals_256hz.csv, signals_1hz.csv and so on, one file per rate, with nothing interpolated. See Mixed sampling rates for the details.
channels.csv
One row per signal channel, whether or not it was converted. The columns are column, signal_index, label, unit, sampling_rate_hz, samples_per_record, physical_min, physical_max, digital_min, digital_max, transducer, prefiltering, output_file and converted.
column is the name that channel has in the signal CSV, output_file says which file it landed in, and converted is yes or no. A channel you filtered out with --channels is still listed here rather than disappearing.
annotations.csv
Written only for EDF+ and BDF+ recordings that carry an annotation channel. Plain EDF files have no events to export.
onset_s,duration_s,description,record_index
0.5,1,Sleep stage W,0
1.25,,Lights off,1
2,0.5,Seizure onset,2
onset_s is on the same clock as time_s in the signal files. duration_s is empty for an event that has no stated duration. record_index is the data record the event was stored in.
metadata.json
Machine-readable provenance: the tool version, the source path, size and modification time, the recording's format, start time, record count and duration, the exact window converted, the row count of every file written, and the full list of warnings. Add --checksum to record a SHA-256 of the input file alongside it.
Check a file before you convert it
--info reads the header only and writes nothing, so it returns immediately whatever the file's size.
edf2csv sleep-study.edf --info
File sleep-study.edf
Format EDF
Recorded 1985-01-01 00:00:00 UTC
Duration 3s (3 records of 1s)
Size 3.3 KB
Patient X X X X
Recording Startdate X X X X
Channels 3 signals
# COLUMN LABEL UNIT RATE RANGE OUTPUT
0 EEG Fpz-Cz EEG Fpz-Cz uV 256 Hz -250 to 250 signals_256hz.csv
1 ECG ECG mV 128 Hz -5 to 5 signals_128hz.csv
2 Temp rectal Temp rectal degC 1 Hz 34 to 40 signals_1hz.csv
Sampling rates differ, so channels are written to 3 files, one per rate. No channel is resampled.
Would write 1,155 rows, roughly 27.4 KB.
Anything the tool noticed is printed after the table, on stderr:
warning: Channels use 3 different sampling rates (256 Hz, 128 Hz, 1 Hz).
They are written to one file per rate so no channel is resampled.
On a long recording, --info tells you four things before you spend any disk:
- The row count and approximate output size.
- The exact channel labels to pass to
--channels, spelled the way the file spells them. - Whether the recording is discontinuous or mixed-rate.
- Any header problem — a truncated file, a record count that disagrees with the data, a channel whose calibration can't be applied.
Because the table goes to stdout and the warnings go to stderr, edf2csv sleep-study.edf --info > structure.txt saves the table on its own.
Convert a slice instead of the whole recording
Give a start, then either a duration or an end.
edf2csv sleep-study.edf --start 30m --duration 5m
edf2csv sleep-study.edf --start 1h --end 1h05m
Times can be a plain number of seconds (90), a unit form (90s, 5m, 1h30m, 250ms), or a clock form (00:30:00, 30:00). All offsets are measured from the start of the recording, not from the wall clock in the header. Passing --duration and --end together is an error, since they answer the same question.
Combine a window with a channel filter and a destination:
edf2csv sleep-study.edf --start 1h --duration 5m \
--channels "EEG Fpz-Cz,ECG" --out ./epoch-42
Channel names must match the LABEL column from --info, though matching is case-insensitive. A name that matches nothing is an error rather than a silent omission. When two channels share a label, address one by position with #N, for example --channels "#0".
Two things to expect from a slice:
time_sisn't rebased. A window starting at one hour begins at3600.000, so rows stay comparable with the full recording and with the events.annotations.csvis filtered to the same window, so you get the events inside the slice.
Opening the result
pandas
import pandas as pd
signals = pd.read_csv("recording_csv/signals.csv")
eeg = signals["EEG Fpz-Cz"]
channels = pd.read_csv("recording_csv/channels.csv")
print(channels[["column", "unit", "sampling_rate_hz"]])
Pass index_col="time_s" to read_csv to get time as the index rather than as a column.
R
signals <- read.csv("recording_csv/signals.csv", check.names = FALSE)
eeg <- signals[["EEG Fpz-Cz"]]
plot(signals$time_s, eeg, type = "l", xlab = "time (s)", ylab = "uV")
Without check.names = FALSE, R rewrites EEG Fpz-Cz into EEG.Fpz.Cz and the column names no longer match the ones in channels.csv or in the original file.
Excel and Numbers
Open signals.csv directly. It's plain UTF-8 CSV with a header row and needs no import wizard. The limit is the row count: spreadsheets stop at 1,048,576 rows including the header, which is about 68 minutes of a single 256 Hz channel. edf2csv warns you before writing when any output file would exceed that:
warning: At least one output file will have more than 1,048,576 rows, which is more than Excel or Numbers can open.
Use --start and --duration to convert a section, or read the file with pandas or R.
channels.csv, annotations.csv and short slices open in a spreadsheet without trouble. Full-length signal files usually don't.
Where to go next
- Output files describes every column of every file the conversion writes, including the whole of
metadata.json. - CLI reference lists every flag, the exit codes, and the
--jsonsummary for scripting. - Mixed sampling rates explains why a mixed-rate recording becomes several files, and what other tools do instead.