Documentation11 of 11

Questions and troubleshooting

Answers to common questions, from several signals files to patient data in metadata.json

Why did I get several signals files instead of one?

Because the channels in your recording weren't all sampled at the same rate. When every channel shares one rate you get a single signals.csv. When they don't, you get one file per rate, named after that rate:

sleep-study_csv/
  signals_100hz.csv    EEG Fpz-Cz, EEG Pz-Oz, EOG horizontal
  signals_10hz.csv     Resp oro-nasal
  signals_1hz.csv      Temp rectal

A single wide table can't hold two rates without inventing rows. Putting a 1 Hz temperature channel next to a 100 Hz EEG channel in one table means filling 99 out of every 100 temperature cells with values that were never measured, so edf2csv splits the table instead. Nothing is resampled, interpolated or padded.

channels.csv has an output_file column telling you where each channel went, and --info shows the same mapping before you convert anything.

If you would rather have one file, --layout long gives you one — by changing the shape rather than the data. Each row is a single sample, carrying its own time, so no channel has to fill in cells for times it was never sampled at:

edf2csv sleep-study.edf --out ./converted --layout long
time_s,channel,value
0.000,EEG Fpz-Cz,0.061
0.000,EEG Pz-Oz,0.061
0.000,EOG horizontal,0.061
0.000,Resp oro-nasal,0.000244
0.000,Temp rectal,37.00073
0.010,EEG Fpz-Cz,1.648

All five channels at the first instant, then the 100 Hz ones again a hundredth of a second later while the 10 Hz and 1 Hz channels wait their turn. Still nothing resampled, interpolated or padded. long.pivot(index='time_s', columns='channel', values='value') in pandas gets you back to the wide form for whichever rates you want it for — except on a recording that samples faster than the time column can separate, or one whose data records overlap, where two rows share a time and a channel and pandas raises ValueError: Index contains duplicate entries. Both are shapes a conversion warns about.

Why is my CSV so much larger than the EDF file?

Because EDF stores each sample as 2 raw bytes (3 for BDF) and CSV stores it as human-readable text. A sample stored as two bytes becomes something like -114.258, which is eight characters plus a comma.

How much larger depends on the channel count as much as on the decimals, because every row carries one time_s cell however many channels share it. A 23-channel 256 Hz montage comes out about 4 times the EDF; a single-channel recording of the same length is nearer 10, since the time column has nothing to share with. Channels needing more decimal places push it up further.

The extra size isn't padding. The decimal places are chosen per channel from its calibration so that no two distinct digital codes round to the same text, and no further digits are written. Trimming them would cost resolution.

If the size is a problem, convert a smaller part of the recording rather than reducing precision:

edf2csv sleep-study.edf --channels "EEG Fpz-Cz,EOG horizontal" --start 1h --duration 20m

Run --info first to see the row count and approximate byte size before writing anything. Compressing the result afterwards works well, since CSV of this kind is very repetitive: gzip signals.csv typically recovers most of the size.

Can I open the output in Excel?

Sometimes. Excel and Numbers stop at 1,048,576 rows including the header. One hour of a single 256 Hz channel is 921,600 rows, so it just fits. Two hours doesn't. When any output file will exceed the limit, edf2csv warns you before writing:

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 and annotations.csv are small and open in a spreadsheet without trouble. For the signal files you have two options.

Convert a window small enough to open:

edf2csv sleep-study.edf --start 22m --duration 30s --out ./excerpt

Or read the full file with something that has no row limit. All the output uses plain RFC 4180 CSV with a single header row, so no dialect arguments are needed:

import pandas as pd
signals = pd.read_csv("sleep-study_csv/signals_100hz.csv")
signals <- readr::read_csv("sleep-study_csv/signals_100hz.csv")

A spreadsheet may also reformat what it displays. A time column of 0.00390625 can be shown as 0.004, and a label such as 1-2 can be read as a date. The file on disk is unaffected, but don't rely on a spreadsheet's rendering when you're checking values.

Why does it refuse to write into a directory that already exists?

To stop a second conversion from mixing itself into the results of a first one. If the output directory exists, the conversion stops before writing anything:

error: "sleep-study_csv" already exists.
       Pass --force to write into it, leaving whatever else it holds, or --out
       to choose a different directory.

That's exit code 1, and nothing on disk has changed. Pick one:

edf2csv sleep-study.edf --force            # overwrite the previous output
edf2csv sleep-study.edf --out ./run-2      # write somewhere else

The check exists because the output is a set of files that only make sense together. metadata.json describes the run that produced the CSVs beside it, and half-replacing that set would leave you with a metadata file describing one conversion and signal files from another.

Why is there a leftover signals_256hz.csv next to my new signals.csv?

--force overwrites the files a run produces, but it doesn't empty the directory first. Convert a mixed-rate recording into a directory, then convert a single-rate one into the same directory, and the rate-named files from the first run are still there, looking current. edf2csv detects this and tells you:

warning: signals_128hz.csv, signals_1hz.csv, signals_256hz.csv are left over from an earlier
         conversion into this directory and were not rewritten.
         Delete them, or convert into a fresh directory, so the two runs do not
         get mixed up.

Nothing is deleted for you. Either delete the stale files or convert into a fresh directory. metadata.json always lists the files the current run actually wrote, under conversion.files, so that's the authoritative list if you're unsure which is which.

I asked for a channel and it says there is no channel with that name

--channels matches the channel's label exactly, ignoring case only. It doesn't do substring or prefix matching, since a partial match would silently pull in channels you didn't ask for. A term that matches nothing is an error rather than a quiet omission:

error: No channel named "EKG". Did you mean "ECG"?
       Run with --info and no --channels to list the channels in this file.

The usual causes are a label with different spacing or punctuation than you expected (EEG Fpz-Cz rather than EEG-Fpz-Cz), or trailing spaces in the file's own header. Run --info and copy the label out of the LABEL column exactly as printed. Labels with spaces need quoting in the shell:

edf2csv sleep-study.edf --info
edf2csv sleep-study.edf --channels "EEG Fpz-Cz,EOG horizontal"

If the label is awkward, or two channels share it, address the channel by its position in the file instead. The # column in --info is that position:

edf2csv sleep-study.edf --channels "#0,#3"

A real label always takes priority over the #N form, so a channel actually labelled #3 stays reachable by name.

Why did --channels give me two columns when I asked for one?

Because two channels in the file share that label. EDF doesn't require labels to be unique, and recordings with two channels both labelled T8-P8 are common enough to be normal rather than corrupt. Both are selected, and you're told why:

warning: "T8-P8" matches 2 channels (positions #0, #1); all of them were selected.
         Use --channels "#0" to pick just one.

In the output the columns are suffixed with the signal position so they stay distinct: T8-P8_ch0 and T8-P8_ch1. The suffix is derived from the whole file, not from your selection, so a given channel always produces the same column name no matter which channels you asked for.

The times in my file jump. Is that a bug?

Almost certainly not. Check metadata.json for "format": "EDF+ (discontinuous)". A discontinuous recording (EDF+D or BDF+D) has real gaps in it: the amplifier was paused, or a review tool exported only the interesting segments. Each data record then carries its own true start time, and edf2csv writes that time, so a gap appears in the time_s column exactly where the recording had one:

time_s,EEG Fpz-Cz
1.700,-12.451
1.800,-11.230
1.900,-10.107
10.000,3.418
10.100,4.639

That file has no data between 1.9 s and 10.0 s because none was recorded. You're warned at conversion time:

warning: This recording is marked discontinuous (EDF+D): its data records need not be contiguous in time.
         Each row carries its true recording time, so gaps stay visible instead
         of being closed.

Other tools handle this differently. mne.io.read_raw_edf closes EDF+D gaps silently, which shifts every sample after the gap to a time it wasn't recorded at. pyEDFlib refuses EDF+D files outright. Keeping the gap visible means time_s is always the real recording time, and that the gap is yours to handle.

Two other causes of odd times, both reported as warnings when they occur: records whose timekeeping annotation is missing get a fallback timestamp computed as if they were contiguous, and a file whose records are stored out of order produces a time_s column that doesn't increase monotonically. Both are named explicitly in the warnings and in metadata.json under notes.

There is no annotations.csv in my output directory

annotations.csv is written only when the recording has an EDF+ or BDF+ annotation channel. Plain EDF has nowhere to store events, so no file is written at all rather than an empty one suggesting that events were looked for and not found.

Run --info. The channel count line names annotation channels separately, so a file with none says only how many signals it has:

Channels   2 signals

against a recording that has one:

Channels   5 signals + 1 annotation channel

When the channel exists but holds no events beyond the per-record timekeeping entries, the file is written with its header row and no data rows, and metadata.json records "annotations_written": 0.

Does edf2csv send my data anywhere?

No. It runs entirely on your machine. The code contains no network calls of any kind: no upload, no download, no update check, no crash reporting, no telemetry, no usage counter. It reads the file you point it at and writes files into the output directory.

It also installs no dependencies at all, so there's no third-party package running in the same process that could do any of the above. The only thing that touches the network is npm or npx when you install the tool, which happens once and is the package manager's doing rather than the tool's.

This matters because clinical and research recordings frequently can't leave the machine or the network they're on.

Is patient information preserved in the output?

Yes, and you should treat metadata.json accordingly.

EDF headers carry two free-text identification fields, and edf2csv copies both into metadata.json verbatim, under recording.patient_id and recording.recording_id. In an EDF+ file the patient field is structured as a patient code, sex, birth date and name, and the recording field holds the start date plus a hospital administration code, the technician and the equipment. In practice these fields contain whatever the recording software put there, which is sometimes a study code and sometimes a person's actual name and date of birth.

metadata.json contains:

Field What it holds
recording.patient_id The 80-character patient identification field, exactly as written in the header
recording.recording_id The 80-character recording identification field, exactly as written
recording.start_datetime_local Recording start as a zone-less wall clock, when the header's date and time parse
recording.start_date_raw The raw dd.mm.yy date field from the header
recording.start_time_raw The raw hh.mm.ss time field from the header
source.path The absolute path of the input file on the machine that ran the conversion
source.bytes, source.modified Size and modification time of the input
source.sha256 Checksum of the input, only when --checksum was passed, otherwise null

Two of those are easy to overlook. A recording date and time is itself identifying when combined with a clinic and a date of admission, so start_datetime_local isn't neutral. And source.path is the resolved absolute path, which often embeds a subject folder name.

The fields are copied rather than stripped so that the conversion stays reproducible, and because what counts as identifying depends on your context. So:

Two other files are worth checking. --info prints Patient and Recording lines to stdout, so terminal transcripts and CI logs pick them up. And annotations.csv holds annotation text exactly as recorded, which is free text a technician typed and can contain names or clinical notes.

signals.csv and channels.csv contain no patient identification. channels.csv does include the transducer and prefiltering strings, which can identify a site's equipment but not a person.

Can I get the raw digital values instead of physical units?

The CSV output is always physical units. There's no flag for raw digital codes, and --decimals 0 rounds physical values to whole numbers rather than giving you the underlying integers.

You have two routes. The first is to recover the digital code from the physical value, which is exact because the mapping is linear and the calibration constants are in channels.csv:

import pandas as pd

channels = pd.read_csv("sleep-study_csv/channels.csv").set_index("column")
row = channels.loc["EEG Fpz-Cz"]

gain = (row.physical_max - row.physical_min) / (row.digital_max - row.digital_min)
offset = row.physical_max / gain - row.digital_max

# EEG Fpz-Cz is a 100 Hz channel, so it is in the 100 Hz table — see the layout above.
signals = pd.read_csv("sleep-study_csv/signals_100hz.csv")
digital = (signals["EEG Fpz-Cz"] / gain - offset).round().astype("int64")

The rounding recovers the original integer exactly, because the written decimals are always fine enough to keep adjacent digital codes distinct. npm run roundtrip checks that across the calibration space — 20,160 cells over 1,260 combinations of digital and physical bounds, EDF and BDF, down to a magnetometer's ±1e-16 and including the bounds written the wrong way round — and every one comes back as the code the file holds.

Two things this depends on. Take the gain from channels.csv rather than from what you believe the recording's range to be: EDF's physical bound fields are 8 characters, so a header asked for -0.000001 stores -0, and the calibration in the CSV is the one the numbers were made with. And leave --decimals alone. The promise is about the precision edf2csv derives per channel; force a coarser one and the codes stop being recoverable, silently — --decimals 0 on a 256 Hz EEG channel gets 645 of 768 samples wrong.

The second route is the programmatic API, which hands you the integers directly and never builds a CSV at all:

import { EdfFile } from "edf2csv";

const file = await EdfFile.open("sleep-study.edf");
const signal = file.dataSignals[0];
const digital = [];

for await (const batch of file.readRecords()) {
  for (let record = 0; record < batch.recordCount; record++) {
    for (let sample = 0; sample < signal.samplesPerRecord; sample++) {
      digital.push(file.sampleAt(batch, record, signal, sample));
    }
  }
}

await file.close();
console.log(signal.label, digital.slice(0, 8));

sampleAt returns the raw two's complement integer, sign-extended from 24 bits for BDF. The batch buffer is reused between iterations, so copy anything you need to keep past the current loop turn.

Why does one channel have three decimals and another five?

Because the number of decimals is derived from each channel's own calibration, not fixed globally. The smallest physical step a channel can express is |physical_max - physical_min| / |digital_max - digital_min|, and edf2csv writes two places past that step. An EEG channel spanning plus or minus 250 uV across a 12-bit converter has a step of about 0.12 uV, so three decimals are enough for every distinct sample to have distinct text. A temperature channel spanning 34 to 40 degC over the same converter has a step near 0.0015, so it gets five.

The result is that no resolution is lost and no meaningless digits are written. The per-channel choice is recorded in metadata.json under conversion.rate_groups[].decimals.

If you need a fixed width across channels, for a downstream tool that insists on it, override it:

edf2csv sleep-study.edf --decimals 6

--decimals accepts a whole number from 0 to 20 and applies to every channel. Setting it below what a channel needs discards resolution, which is why it isn't the default.

Does it support BDF and BioSemi files?

Yes. BDF and BDF+ are read natively. BioSemi's format is EDF with 24-bit samples instead of 16-bit and its own version marker, and edf2csv handles both: samples are decoded as 24-bit little-endian two's complement, and BDF Annotations is recognised alongside EDF Annotations as the events channel. BDF+C and BDF+D are treated exactly as EDF+C and EDF+D.

The format is reported in --info and in metadata.json:

Format     BDF+ (discontinuous)

Nothing else about the workflow changes: the same flags, the same output files, the same rules about sampling rates and gaps.

Does it do filtering, detrending or artifact removal?

No. edf2csv applies exactly one transformation: the digital-to-physical scaling that the file's own header specifies. No filtering, no notch, no detrending, no re-referencing, no artifact rejection, no resampling, no unit conversion, no scaling to a common range.

Preprocessing belongs in your analysis, where the choices are visible and reviewable. A converter that filtered on the way out would produce a CSV that disagrees with the EDF for reasons recorded nowhere.

Two consequences. Prefiltering that the recording hardware already applied is described in the prefiltering column of channels.csv, so you can see what was done before the file existed. And a channel whose header declares its physical minimum above its physical maximum is converted with that inversion intact, since correcting it would mean overriding what the file says:

warning: Signal 3 ("inverted") declares physical minimum 100 above physical maximum -100, which inverts its polarity.
         The values are converted exactly as the header specifies, inversion
         included.

What happens with a truncated recording, or one that is still being written?

A truncated file converts. edf2csv derives the number of data records from the actual file size rather than trusting the header, converts every complete record that's present, and warns about the discrepancy:

warning: The header declares 10 data records but the file contains 4. Only the 4 records that are present can be converted.
         The recording looks truncated. It may have been cut short or copied
         incompletely.

If bytes are left over after the last complete record, they're ignored and reported separately as a TRAILING_BYTES warning. Both warnings are also written into metadata.json under notes. If there isn't even one complete data record, the conversion fails with exit code 1 rather than producing a file with a header row and nothing in it.

A recording still in progress often declares -1 data records, which the spec permits. That's handled the same way:

warning: The header does not say how many data records the file has (-1), which the spec allows
         for recordings still in progress. Using the 4 records the file actually contains.

Converting a file that's actively being appended to works, with one catch: the file size is read once when the file is opened, so records written after that point aren't included. You get a clean conversion of the recording as it stood at that instant. If the file instead becomes shorter while it's being read, which happens when a writer rewrites it in place, the conversion fails rather than handing you a silently short result:

error: Expected 524,288 bytes of data at record 1024 but only 131,072 bytes were available; the file
       appears to have changed size while it was being read.
       Make sure the recording is not still being written to, then try again.

The values do not match what another tool gave me

Check the low-order digits before assuming a bug. edf2csv computes each physical value as gain * (offset + digital), which is the arrangement EDFlib uses, and the results are bit-for-bit identical to pyEDFlib on the recordings used for testing.

The specification writes the same mapping as (digital - digitalMin) * gain + physicalMin. That form is algebraically equivalent but numerically worse: on a channel spanning plus or minus 800 uV it computes a value near 800 and then subtracts 800, and the cancellation discards low-order bits. Digital code 0 comes out as 0.19536019536019467 when the correctly rounded value is 0.19536019536019536. A tool using the literal ordering will differ from edf2csv in the last few digits, and the edf2csv value is the correctly rounded one.

Larger disagreements usually have a structural cause rather than an arithmetic one. If another tool gave you more rows than edf2csv did, it probably upsampled the slow channels to a common rate. If it gave you a continuous time axis for a file edf2csv split with a gap, it closed an EDF+D discontinuity. Compare against channels.csv and metadata.json, which state the rate and row count of every file that was written.

How do I convert a whole directory of recordings?

Point it at the folder:

edf2csv /data/recordings --out /data/converted --jobs auto

Every .edf and .bdf inside is converted, at any depth — the extension is matched without regard to case, so .EDF and .Bdf count — and each gets its own directory under /data/converted keeping the position it had. Naming the files individually works too:

edf2csv /data/recordings/*.edf --out /data/converted

Each recording gets its own directory inside /data/converted, named after the file. Leave --out off and each converts beside itself into <name>_csv instead.

A file that cannot be read is reported and the rest still convert; the run exits non-zero and the closing line says how many succeeded. Before this was possible the answer here was a shell loop, which still works and is still the right tool when you want to do something between files:

for f in /data/recordings/*.edf; do
  edf2csv "$f" --out "/data/converted/$(basename "${f%.edf}")"
done

Add --force if you expect to rerun the loop over the same destinations, and --quiet to keep the per-file summaries out of the log while still seeing warnings and errors. To stop the whole loop on the first failure, check the exit code:

set -e
for f in /data/recordings/*.edf; do
  edf2csv "$f" --out "/data/converted/$(basename "${f%.edf}")" --quiet
done

How much memory does a large file need?

Very little, and it doesn't scale with the length of the recording. Conversion is streamed: records are read in batches of about 8 MB, converted, and written out, so a 4 GB recording uses the same working set as a 4 MB one. A 40 MB EDF producing a 159 MB CSV converts in about 1.4 seconds with the Node heap capped at 48 MB.

All the output files are written in the same single pass over the data, so a recording that produces three rate-split files is still read exactly once.

Two things do scale with the recording, and neither is its length.

The EDF+ annotation list is collected in memory before it's written, because annotations have to be sorted by onset and a writer is free to store an event in a record other than the one its onset falls in. A recording with hundreds of thousands of events uses memory in proportion to the event count.

And one data record is read whole, because a record is the unit the format is addressed in and there is nothing smaller to divide it into. Records are almost always well under the 8 MB batch — a second of 60 channels at 200 Hz is 24 KB — but the header permits far larger, and a recording whose records are gigabytes needs room for one of them. Reading such a record works; it is the one case where the working set follows the file.

The rows a record produces do not: until 0.4.54 they were held until the record ended, so a recording of one enormous record ran out of heap where the same samples split across many records converted fine. The row buffer is now emptied whenever it fills, wherever in the record that happens.

Channel count used to be a third, and no longer is. Each channel gets a cache of its formatted values — the same handful of strings serve millions of rows — sized to the digital range it declares, up to 512 KB for one declaring the whole 16-bit range, which is what an ordinary EEG amplifier declares. A per-channel ceiling bounds nothing about a file's channel count, so a 229 KB recording of 256 such channels reserved 134 MB of pointers before writing a row, and died out of heap under any cap below the 192 MB that reservation forced it to need. Since 0.5.52 the caches share one 16 MB budget for the conversion, handed out fastest rate first; channels past it format each value directly, which is slower and produces exactly the same text.

How do I check whether a conversion had problems from a script?

Use the exit code for pass or fail, and --json for the detail. The exit codes are 0 for success, 1 for a problem with the file or the output directory, and 2 for a problem with how the command was invoked. Two more matter to a script: --strict also exits 1, for a conversion that wrote its output and merely raised a warning, so a pipeline using it cannot read 1 as "nothing was written"; and an interrupted run exits 130 for Ctrl-C or 143 for SIGTERM. The second is the one a script is likelier to meet, since timeout, systemd, a CI runner and a container stop all send SIGTERM — a pipeline that only tests for 130 reads a killed run as an ordinary failure.

edf2csv sleep-study.edf --out ./converted --json > result.json

--json writes a summary to stdout and nothing else, so it can be piped or parsed directly:

{
  "tool": { "name": "edf2csv", "version": "..." },
  "output_dir": "./converted",
  "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 }
  ],
  "annotations": 7,
  "duration_seconds": 28800,
  "records": 28800,
  "elapsed_ms": 1141,
  "warnings": [
    {
      "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."
    }
  ]
}

Every warning the run raised appears in the warnings array with a stable code, so a script can react to a specific condition rather than matching on message text:

edf2csv sleep-study.edf --json \
  | node -e 'process.stdin.toArray().then(c => {
      const codes = JSON.parse(c.join("")).warnings.map(w => w.code);
      if (codes.includes("RECORD_COUNT_MISMATCH")) process.exit(1);
    })'

Under --json the warnings go into the JSON on stdout instead of being printed to stderr, so you won't see them twice. Without --json, warnings and the summary go to stderr and the only things on stdout are --info's description and, under --stdout, the signal CSV itself — so a conversion can run inside a pipeline without mixing messages into the data.

How do I cite edf2csv, or pin a version?

Pin the version wherever the tool is invoked, so a rerun a year from now produces the same bytes:

npx edf2csv@0.2.0 sleep-study.edf
npm install -g edf2csv@0.2.0

Every conversion already records which version produced it. metadata.json opens with:

{
  "tool": {
    "name": "edf2csv",
    "version": "..."
  }
}

Add --checksum and the SHA-256 of the input file is recorded alongside it, under source.sha256, so the input is pinned as well as the tool:

edf2csv sleep-study.edf --checksum

For a methods section, name the tool, the version and the repository, and state the one non-obvious thing the conversion did:

EDF recordings were converted to CSV with edf2csv 0.2.0 (https://github.com/tayal-sarthak/edf2csv). Channels recorded at different sampling rates were written to separate files and were not resampled.

edf2csv is MIT licensed, so it can be redistributed, vendored into a pipeline or included in supplementary material without restriction. edf2csv --version prints the version of whatever copy you're running.

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