Documentation3 of 11

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 (--bom adds one)
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 — the instant recording.start_datetime_local names in metadata.json — so adding one to the other gives an absolute instant. It isn't a wall clock and isn't a Unix timestamp.

Zero is usually the first sample, and does not have to be. An EDF+ file's first data record carries a timekeeping annotation stating where that record sits relative to the header's start time, and edf2csv uses it rather than assuming zero: a recording whose first record says +0.5 writes its first row as 0.500, and one saying +30 writes 30.000. That is a property of the annotation, not of continuity — a plain EDF+C file can begin anywhere. --info prints a Timed from line whenever it is not zero, and --start and --end are read on the same clock.

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 fifteen 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. Fifteen is the bound because 10^16 is past 2^53, where the integer test stops being able to tell.

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 10 yes
4096 Hz 0.000244140625 12 yes
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.

One kind of rate falls outside this: one whose reciprocal doesn't terminate at all, such as 3 Hz. It gets enough places to keep consecutive samples distinct and no more, and 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.

Every power of two through 32768 Hz terminates inside fifteen places, so every rate a recording is likely to use is written exactly. Up to 0.5.23 this section said the search stopped at nine and listed 1024 Hz as rounded at seven places; the bound has been fifteen since 0.4.55, and 1024 Hz gets ten and is exact.

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

Three 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|

Both differences are magnitudes. Either pair may be written the wrong way round — the header is free to say physical_min 100, physical_max -100, or to reverse the digital pair, and both happen — and a step is a size, so the sign is dropped. That is what makes an inverted channel get the same precision as the upright one it inverts, which is the only answer that keeps every one of its distinct codes distinguishable.

edf2csv writes ceil(-log10(step)) + 2 decimals, clamped to the range 0 to 100. 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 100 because that is the most toFixed will print — 101 is a RangeError — and nothing short of that is a principled place to stop. It was 20 until 0.4.74, on the stated grounds that 20 was toFixed's limit, which it is not. The difference showed on the channel type this paragraph already named: a magnetometer spanning ±1e-16 T over a 16-bit converter steps by 3.05e-21 and needs 23 places, so at 20 its values landed on a 1e-20 grid, roughly three digital codes to a printed value, and 69% of them could not be recovered. Nothing warned.

Reaching 100 takes a step below 1e-98, which an 8-character physical bound can still express — 1e-99 is five characters. A channel that does raises a VALUE_RESOLUTION warning rather than losing precision in silence.

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 wide table — a column per channel — without inventing values for the slow channels, so each distinct rate gets its own file:

edf2csv recording.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.

The other answer is not to make the rates share a row at all. --layout long writes one file whatever the rates are — time_s, channel, value, one row per sample — so each sample keeps its own time and nothing has to line up:

time_s,channel,value
0.00000000,EEG Fpz-Cz,0.061
0.00000000,ECG,0.00122
0.00000000,Temp rectal,37.00073
0.00390625,EEG Fpz-Cz,9.096

Nothing is invented there either; it is the same samples in a different shape. The cost is size, since every row repeats the time and the channel name.

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, and is written in the same notation that file is named in — until 0.8.39 it was rendered on its own, so a rate of 1e-19 sat in the row naming signals_1_000e-19hz.csv. Written Infinity when the record duration is too small to divide into, where both JSON documents have to write null.
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. Written as plain decimal at any magnitude, the way signals.csv writes the values they scale: a channel calibrated to ±1e-16 T reads -0.0000000000000001, not -1e-16, so the column holds one notation whatever its rows mix.
digital_min, digital_max Calibration range in raw converter counts, as declared, in the same plain decimal.
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 carry the values the header declares, so a channel whose physical_min sits above its physical_max survives into the file that way rather than being corrected. The notation is this file's rather than the header's — -1.00e-9 in the header is -0.000000001 here — since a column that is decimal text in one row and exponent text in the next is a column a reader has to parse twice. That by itself is not what makes a channel inverted. The gain is (physical_max - physical_min) / (digital_max - digital_min), so it is the sign of the whole fraction that decides: reverse one pair and the polarity is inverted, reverse both and the gain comes out positive and the channel is perfectly ordinary — no warning, and none is warranted.

When the gain really is negative, the values in signals.csv are converted exactly as the header specifies, inversion included, and an INVERTED_PHYSICAL_RANGE warning names the channel and whichever pair is the wrong way round.

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. Also empty when the file stated one that is not a number, which raises an ANNOTATION_DECODE_FAILED warning saying how many rows that happened to — the cell itself cannot tell the two apart.
description The annotation text, copied verbatim — decoded as UTF-8 where the bytes are UTF-8 and as latin1 where they are not, so nothing the file does not hold appears in the cell. Quoted per the CSV rules when it contains a comma, a quote or a newline. It is free text, and where it lands matters: one starting with =, + or @ is a formula to a spreadsheet and raises FORMULA_LABEL, and one carrying a control byte or a bidirectional override raises NONPRINTABLE_LABEL. Neither is rewritten — the cell says what the recording says — but neither is silent either.
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 bounds are the ones you asked for rather than the window after it was clamped to the recording, so an end you did not give stays unbounded: --end 999h and --start 0 both keep an event sitting at or past the last sample, exactly as a run with no time options does. 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": "..."
  },
  "source": {
    "path": "/data/recordings/sleep-study.edf",
    "bytes": 19643392,
    "modified": "2026-03-14T09:12:44.000Z",
    "sha256": "aa8b902eb999a58b20122396a39b8db7a12d4e9c93b8447e6b3f374d43e7dc2c"
  },
  "recording": {
    "format": "EDF+ (continuous)",
    "version": "0",
    "patient_id": "X X X X",
    "recording_id": "Startdate 02-MAR-2002 X X X",
    "start_datetime_local": "2002-03-02T23:10:00",
    "start_date_raw": "02.03.02",
    "start_time_raw": "23.10.00",
    "data_records": 28800,
    "data_records_declared": 28800,
    "record_duration_seconds": 1,
    "duration_seconds": 28800,
    "signal_count": 6,
    "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": 7,
    "layout": "wide",
    "bom": false,
    "files": [
      { "name": "signals_100hz.csv", "rows": 2880000 },
      { "name": "signals_10hz.csv", "rows": 288000 },
      { "name": "signals_1hz.csv", "rows": 28800 },
      { "name": "annotations.csv", "rows": 7 },
      { "name": "channels.csv", "rows": 5 }
    ],
    "rate_groups": [
      {
        "file": "signals_100hz.csv",
        "sampling_rate_hz": 100,
        "channels": ["EEG Fpz-Cz", "EEG Pz-Oz", "EOG horizontal"],
        "decimals": [3, 3, 3]
      },
      {
        "file": "signals_10hz.csv",
        "sampling_rate_hz": 10,
        "channels": ["Resp oro-nasal"],
        "decimals": [6]
      },
      {
        "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 (100 Hz, 10 Hz, 1 Hz)."
    },
    {
      "code": "LARGE_OUTPUT",
      "severity": "warning",
      "message": "At least one output file will have more than 1,048,576 rows, which is more than Excel or Numbers can open."
    }
  ]
}

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 describe the file as it was when the conversion opened it — the same size every record count and window below was derived from — rather than whatever is at that path when the run finishes.

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.

The hash is taken before any record is read, and published only if the file held still for the whole conversion. If the size or the modification time moved at any point, sha256 comes back null and the run raises INPUT_CHANGED: a file overwritten in place keeps its inode, so the bytes that were converted are simply gone by then, and a plausible hash of the wrong bytes is worse than no hash at all. The CSVs and the rest of metadata.json are still correct for the data that was read. This is the ordinary outcome of converting a recording that is still being written; convert again once it is finished.

warning: The input changed while it was being converted, so this output covers the file as it
         was when the conversion started, not as it is now.
         No checksum was recorded: the bytes that were converted are no longer
         there to hash. Convert again once the recording is finished.

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. All but one: STALE_OUTPUT is noticed after this file has been written, and says something about the destination rather than about the recording, so it reaches the terminal and --json and never the archive. An empty array means the recording parsed cleanly, and that leftovers from an earlier conversion are the one thing it does not rule out.

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.

Read this page as plain Markdown, or the whole documentation as one text file.