Output files

Every file a conversion writes, column by column, including time semantics, column naming and value precision

What a conversion writes

A conversion writes a directory, not a single file. Given sleep-study.edf and no --out, the directory is sleep-study_csv beside the input: the file's name with its extension removed and _csv appended.

edf2csv sleep-study.edf
sleep-study_csv/
  signals.csv      the samples
  channels.csv     one row describing each channel in the recording
  annotations.csv  the EDF+ event list, when the recording has one
  metadata.json    what was converted, from what, and what was unusual about it

Which of these appear is governed by four rules:

If the output directory already exists, the conversion stops with exit code 1 and writes nothing. Pass --force to overwrite, or --out to choose somewhere else.

The CSV dialect

All three CSV files use the same conventional dialect:

Property Value
Encoding UTF-8, no byte order mark
Line ending LF (\n), including a final newline at end of file
Delimiter Comma
Header Exactly one row, always present
Quoting RFC 4180, minimal
Missing value Empty field, never NA or null

Minimal quoting means a field is wrapped in double quotes only when it contains a comma, a double quote, a carriage return or a line feed. Embedded double quotes are doubled. Nothing else is quoted, so numeric columns are never quoted and a label like EEG Fpz-Cz is written as it stands. This is what pandas.read_csv, readr::read_csv and csv.reader assume by default, so no dialect arguments are needed on the reading side.

The header row isn't a comment and isn't preceded by any preamble. Row one is column names, row two is data.

signals.csv

One row per sample instant, one column per converted channel, plus a leading time column.

time_s,EEG Fpz-Cz,EOG horizontal,ECG
0.000,0.061,-12.454,0.30273
0.010,15.324,-12.332,0.31494
0.020,30.281,-12.210,0.32715

Columns appear in the order the channels appear in the file, which is the same order as signal_index in channels.csv. Every value in the file was recorded. Nothing is interpolated, smoothed, resampled or filled.

The time_s column

time_s is seconds elapsed since the start of the recording. Zero is the first sample of the first data record. It isn't a wall clock and isn't a Unix timestamp. To get an absolute instant, add time_s to recording.start_datetime_local in metadata.json.

Three properties of the column:

It stays absolute when you convert a window. --start 30m --duration 5m produces a file whose first row reads 1800.000, not 0.000. Times refer to positions in the recording, so a section converted on its own lines up with the full conversion, with the annotation onsets, and with any other section.

Window edges are half-open. A row is written when time_s >= start and time_s < end, so --start 0 --end 30 and --start 30 --end 60 together produce every row exactly once, with none repeated at the seam.

On a discontinuous recording it jumps. In an EDF+D file each data record carries its own start time in a timekeeping annotation, and edf2csv uses that time rather than assuming records sit end to end. A recording that pauses for eight seconds after two seconds of data produces this:

1.800,2.259
1.900,2.381
10.000,2.503
10.100,2.625

Rows are written in file order, so if a file's records are stored out of chronological order the column won't increase monotonically, and a DISCONTINUOUS warning says so. If a record's timekeeping annotation is missing or unreadable, that record is timed as if it were contiguous and an ANNOTATION_DECODE_FAILED warning names the affected records.

How many decimals time_s carries

Sample times are written with a fixed number of decimals chosen from the sampling rate, with a minimum of three.

The interval between samples is 1 / rate. That fraction has a terminating decimal expansion of d places exactly when 10^d divides evenly by the rate. edf2csv searches for the smallest such d up to nine places and uses it, so sample times are written exactly rather than rounded, and time_s * rate comes back as a whole row number instead of 8191.999999.

Sampling rate 1 / rate Decimals in time_s Exact?
1 Hz 1 3 yes
100 Hz 0.01 3 yes
250 Hz 0.004 3 yes
256 Hz 0.00390625 8 yes
500 Hz 0.002 3 yes
512 Hz 0.001953125 9 yes
1000 Hz 0.001 3 yes
1024 Hz 0.0009765625 7 rounded
3 Hz 0.333... 4 rounded

256 Hz is the case that comes up most in practice. Written with three decimals, sample 1 of a 256 Hz channel would be 0.004, and dividing that back by the sample period wouldn't return 1. Written with eight, it's 0.00390625, the exact value, and time_s * 256 is an integer for every row in the file.

Two kinds of rate fall outside this. A rate whose reciprocal doesn't terminate at all, such as 3 Hz, gets enough places to keep consecutive samples distinct and no more. A rate needing more than nine places, such as 1024 Hz, is capped and the times are rounded. In both cases the column is marked "rounded" above: the times are accurate to within a fraction of a sample period, but multiplying them by the rate won't land on exact integers.

--decimals doesn't affect this column. It sets the precision of the signal values only.

Column names

A channel's column is its EDF label, copied verbatim. EEG Fpz-Cz stays EEG Fpz-Cz, spaces, hyphens, case and all. Nothing is slugified, lowercased or stripped, since the label is how you recognise the channel and rewriting it would break the correspondence with the recording's own documentation.

Two exceptions:

Names are derived from the whole file, not from your selection. A channel produces the same column name whether you convert everything or ask for it alone with --channels, so files from different runs can be joined without renaming anything. The mapping from column name back to signal position is recorded in channels.csv.

Column names go through the same minimal quoting as any other field, so a label containing a comma is quoted and a label containing a double quote has it doubled.

How many decimals each value carries

Precision is chosen per channel from that channel's own calibration, not fixed globally.

An EDF sample is an integer from the analog-to-digital converter, and the header says which physical range that integer range spans. The smallest physical difference the channel can express is one digital step:

step = |physical_max - physical_min| / (digital_max - digital_min)

edf2csv writes ceil(-log10(step)) + 2 decimals, clamped to the range 0 to 15. The two extra places put rounding error well below the resolution the hardware recorded, so no two distinct digital codes round to the same text, without padding the file with digits that carry no information.

Channel Physical range Digital range Step Decimals
EEG Fpz-Cz -250 to 250 uV -2048 to 2047 0.1221 uV 3
ECG -5 to 5 mV -2048 to 2047 0.002442 mV 5
Temp rectal 34 to 40 degC -2048 to 2047 0.001465 degC 5
A1 (24-bit BDF) -262144 to 262144 uV -8388608 to 8388607 0.03125 uV 4

The upper clamp is 15 because a channel calibrated in volts rather than microvolts has a step near 1e-7, and a magnetometer channel smaller still. A lower cap would round genuinely different samples to the same text.

Two details of the formatting:

Pass --decimals <n> to override the derived precision and use the same number of places on every channel. That's useful for diffing two conversions or for shrinking a file, but it can round distinct samples together, which is why it isn't the default.

The value itself is computed as gain * (offset + digital), EDFlib's arrangement of the EDF calibration formula rather than the specification's literal ordering. The two are algebraically equal but not numerically equal: the literal form computes a large intermediate and then subtracts a large constant, and the cancellation drops low bits. The arrangement used here returns the correctly rounded result, and it's bit-for-bit identical to pyEDFlib and EDFbrowser, which share the same arithmetic.

One file per sampling rate

Recordings often mix rates. A sleep study may hold EEG at 256 Hz, ECG at 128 Hz and rectal temperature at 1 Hz. These can't share one table without inventing values for the slow channels, so each distinct rate gets its own file:

edf2csv sleep-study.edf --out ./converted
converted/
  signals_256hz.csv   time_s, EEG Fpz-Cz
  signals_128hz.csv   time_s, ECG
  signals_1hz.csv     time_s, Temp rectal
  channels.csv
  metadata.json

Each file has its own time_s column with its own decimal precision, and every row in every file is a sample that was recorded. A MIXED_SAMPLING_RATES warning tells you this happened.

The filename is signals_<rate>hz.csv, where a fractional rate has its decimal point replaced by an underscore so the name is safe on every filesystem: 12.5 Hz becomes signals_12_5hz.csv and 0.5 Hz becomes signals_0_5hz.csv.

When every converted channel shares one rate, there's one group and the file is called signals.csv. This means the filename depends on the recording and, if you use --channels, on your selection: selecting only the 256 Hz channels out of a mixed-rate file yields a plain signals.csv. Read conversion.rate_groups in metadata.json if a script needs to know the names without guessing.

Joining the rates means deciding what to do about the mismatch. Merging on time_s with a nearest or backward-fill strategy is one answer, and it's a decision to make in your own code with the original sample times in front of you.

channels.csv

One row per signal channel in the recording, whether or not it was converted. The EDF+ annotation channel isn't a signal and isn't listed.

column,signal_index,label,unit,sampling_rate_hz,samples_per_record,physical_min,physical_max,digital_min,digital_max,transducer,prefiltering,output_file,converted
EEG Fpz-Cz,0,EEG Fpz-Cz,uV,256,256,-250,250,-2048,2047,,,signals_256hz.csv,yes
ECG,1,ECG,mV,128,128,-5,5,-2048,2047,,,signals_128hz.csv,yes
Temp rectal,2,Temp rectal,degC,1,1,34,40,-2048,2047,,,signals_1hz.csv,yes
Column Meaning
column The column name this channel uses in the signals file, after the empty-label and duplicate-label rules. Join on this to attach units to a signals column.
signal_index Position of the channel in the file, counting from 0 and counting the annotation channel if present. This is the identifier --channels "#2" addresses, and the only stable one when labels collide.
label The label exactly as stored in the EDF header, with no disambiguating suffix. Where two rows share a label they'll differ in column.
unit The physical dimension from the header, verbatim: uV, mV, degC, %. Files vary in spelling and some leave it blank. Nothing is normalised.
sampling_rate_hz samples_per_record / record_duration_seconds. This decides which output file the channel lands in.
samples_per_record Samples this channel stores in each EDF data record, straight from the header.
physical_min, physical_max Calibration range in the unit above, as declared.
digital_min, digital_max Calibration range in raw converter counts, as declared.
transducer Free-text electrode or sensor description from the header, often blank.
prefiltering Free-text filter description from the header, for example HP:0.1Hz LP:75Hz N:50Hz. Often blank. Read it before you filter the data again.
output_file Name of the CSV holding this channel's samples, or empty when the channel wasn't converted.
converted yes or no.

The four calibration columns are written as they appear in the header, so physical_min above physical_max — an inverted channel — survives into the file rather than being corrected. The values in signals.csv are converted exactly as the header specifies, inversion included, and an INVERTED_PHYSICAL_RANGE warning points at the channel.

converted is no in three situations: you used --channels and didn't ask for this one, you used --annotations-only so nothing was converted, or the channel declares zero samples per record and therefore holds no data. In every case the row is still present, so channels.csv describes the whole recording and not only what you exported.

annotations.csv

Written whenever the recording has an EDF+ or BDF+ annotation channel. One row per event.

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
Column Meaning
onset_s Seconds from the start of the recording, on the same scale as time_s in the signals files, so the two join directly.
duration_s Length of the event in seconds, or empty when the event carries no duration.
description The annotation text, decoded as UTF-8 and copied verbatim. Quoted per the CSV rules when it contains a comma, a quote or a newline.
record_index The data record the annotation was stored in, counting from 0. Useful for tracing an event back to its position in the source file.

An absent duration is written as an empty field, never as 0. The distinction is real: EDF+ lets an annotation mark an instant with no extent, and writing that as a zero-second event would be a claim the file doesn't make. In pandas the column reads as NaN with no extra arguments; treat NaN as "instantaneous or unspecified" rather than "zero length".

Rows are sorted by onset_s, with ties broken by record_index. onset_s and duration_s are written in their natural numeric form, so 0.5, 1.25 and 2 all appear as such, without padding to a fixed decimal count.

Two things don't appear as rows. The timekeeping annotation that starts each data record carries the record's position in time and no text, so it's used for timing and not exported as an event. And annotations whose onset falls outside a requested --start / --end window are excluded, on the same half-open rule as the signal rows. The whole annotation channel is read even when a window was requested, because an event inside the window may be stored in a record outside it.

If an annotation is malformed, it's skipped rather than aborting the conversion, and an ANNOTATION_DECODE_FAILED warning reports how many were lost.

metadata.json

A record of what was converted, from what, when, and what was unusual about it. This is what makes a conversion reproducible six months later.

{
  "tool": {
    "name": "edf2csv",
    "version": "0.2.0"
  },
  "source": {
    "path": "/data/recordings/sleep-study.edf",
    "bytes": 41236992,
    "modified": "2026-03-14T09:12:44.000Z",
    "sha256": "9f2c7a1d4b6e8035c1af92be7d40e5a13c8f66b0d29e47ab5c1e0f83d76a2b41"
  },
  "recording": {
    "format": "EDF+ (continuous)",
    "version": "0",
    "patient_id": "X X X X",
    "recording_id": "Startdate X X X X",
    "start_datetime_local": "2026-03-13T22:04:00",
    "start_date_raw": "13.03.26",
    "start_time_raw": "22.04.00",
    "data_records": 28800,
    "data_records_declared": 28800,
    "record_duration_seconds": 1,
    "duration_seconds": 28800,
    "signal_count": 4,
    "annotation_channels": 1
  },
  "conversion": {
    "converted_at": "2026-03-20T11:35:02.418Z",
    "start_seconds": 0,
    "end_seconds": 28800,
    "whole_recording": true,
    "records_converted": [0, 28800],
    "annotations_written": 412,
    "files": [
      { "name": "signals_256hz.csv", "rows": 7372800 },
      { "name": "signals_128hz.csv", "rows": 3686400 },
      { "name": "signals_1hz.csv", "rows": 28800 },
      { "name": "annotations.csv", "rows": 412 },
      { "name": "channels.csv", "rows": 3 }
    ],
    "rate_groups": [
      {
        "file": "signals_256hz.csv",
        "sampling_rate_hz": 256,
        "channels": ["EEG Fpz-Cz"],
        "decimals": [3]
      },
      {
        "file": "signals_128hz.csv",
        "sampling_rate_hz": 128,
        "channels": ["ECG"],
        "decimals": [5]
      },
      {
        "file": "signals_1hz.csv",
        "sampling_rate_hz": 1,
        "channels": ["Temp rectal"],
        "decimals": [5]
      }
    ]
  },
  "notes": [
    {
      "code": "MIXED_SAMPLING_RATES",
      "severity": "warning",
      "message": "Channels use 3 different sampling rates (256 Hz, 128 Hz, 1 Hz)."
    }
  ]
}

The file is UTF-8, indented with two spaces, and ends with a newline.

tool and source: where the data came from

tool.name and tool.version identify the converter. Quote the version in a methods section; a future version that changes any formatting rule will say so here.

source.path is the absolute path of the input as resolved at conversion time. source.bytes and source.modified come from the filesystem, with modified as an ISO 8601 instant in UTC.

source.sha256 is null unless you passed --checksum, which reads the input a second time to hash it. With a hash recorded, anyone holding the original can establish that the CSVs came from that exact file, and you can detect a re-export or a partial copy that kept the same size and name. It costs one extra read of the input, which is worth it for anything you intend to publish or archive.

recording: what the header said

conversion: what this run did

notes: every diagnostic, in the archive

notes carries every diagnostic the conversion raised, each with a code, a severity and a message. These are the same warnings printed to standard error during the run, preserved so they stay attached to the data rather than scrolling out of a terminal. An empty array means the recording parsed cleanly.

Read the diagnostics before you analyse the data. MIXED_SAMPLING_RATES explains why you have three signal files. RECORD_COUNT_MISMATCH says the recording is shorter than its header promised. DEGENERATE_DIGITAL_RANGE says a channel's calibration is self-contradictory, which is why that column is empty. DISCONTINUOUS says the gaps in the time column are real.

Leftovers from an earlier run

--force overwrites the files a conversion writes, but it doesn't empty the directory first. Converting a mixed-rate recording into a directory and then converting a single-rate one into the same place leaves signals_256hz.csv sitting next to a fresh signals.csv, with only one of them current.

edf2csv detects this and warns with STALE_OUTPUT, naming the files that weren't rewritten. It deletes nothing, since which of the two conversions you meant to keep isn't something the converter can determine. Delete them yourself, or convert into a fresh directory.