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:

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:

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:

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