EDFplus
A typed Python library for reading EDF and EDF+ biosignal files.
What is EDF?
European Data Format (EDF) is a widely adopted, open standard for storing multichannel biological and physical signals such as EEG, EMG, ECG, polysomnography (PSG), and other physiological time-series recordings. An EDF file consists of a fixed-size ASCII header followed by 16-bit integer data records, making it compact and simple to parse.
EDF+ extends the original format with:
- Continuous (
EDF+C) and discontinuous (EDF+D) recording modes - Time-stamped Annotations Lists (TALs) embedded directly in the data stream
- Structured patient and recording identification subfields
The full specification is available at edfplus.info.
Features
| Category | Feature | |
|---|---|---|
| 📄 Format Support | EDF / EDF+ | ✅ |
| EDF+C / EDF+D | ✅ | |
| 📂 Read From | String path or PathLike |
✅ |
| Binary file objects | ✅ | |
Any seekable stream (BinaryIO) |
✅ | |
| 🏷️ Metadata | Full header field access (patient, recording, signals) | ✅ |
| EDF+ annotations & TALs | ✅ | |
| Normalized & typed subfields | ✅ | |
| 📊 Signal Data | Lazy loading | ✅ |
| Cherry-pick data | ✅ | |
| Or grab everything at once | ✅ | |
| Physical (scaled float) or digital (raw int16) | ✅ | |
| Pick your dtype — your RAM will thank you | ✅ | |
| 🕐 Timestamps | Timezone correction | ✅ |
| Per-sample timestamps (seconds) | ✅ | |
| Per-sample datetimes (absolute) | ✅ | |
| Onset-relative timing | ✅ | |
| 🔎 Read Filters | Slice by seconds or by index | ✅ |
| Slice by datetimes or durations | ✅ | |
| 🧠 Fully Typed | Complete type annotations across the public API | ✅ |
PEP 561 py.typed — your type checker is happy |
✅ |
Installation
# uv
uv add edfplus
# others
poetry add edfplus
pip install edfplus
Requires Python 3.12+ and NumPy.
Quickstart
from edfplus import read_edf
with read_edf("recording.edf") as edf:
# Inspect the header
print(edf.header.variant) # "EDF", "EDF+C", or "EDF+D"
print(edf.header.start_datetime) # Recording start time
print(edf.header.num_signals) # Total signal count
# Iterate over signals
for signal in edf.signals:
print(f"{signal.label}: {signal.sample_rate} Hz, {len(signal.samples)} samples")
# Access a signal by label or index
eeg = edf["EEG Fp1"]
first = edf[0]
# Load a time slice (seconds)
chunk = eeg.load(start=10.0, stop=20.0)
# Get samples with timestamps
samples, timestamps = eeg.load_with_timestamps(onset=5.0, duration=3.0)
# Read EDF+ annotations
for ann in edf.annotations:
print(f"{ann.onset:.2f}s: {ann.text}")
read_edf() reference
from edfplus import read_edf
edf = read_edf(
source, # str, Path, or binary file-like object
*,
physical=True, # Scale samples to physical units (float64)
dtype=None, # Cast physical values to a specific NumPy dtype
tzinfo=None, # Attach timezone info to parsed datetimes
eager_load_samples=False, # Load all samples immediately instead of lazily
)
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
source |
str | Path | BinaryIO |
(required) | Path to an EDF/EDF+ file, or an open binary stream with .read() and .seek(). |
physical |
bool |
True |
When True, samples are scaled from raw int16 digital values to physical units using the calibration values in the header. When False, raw int16 values are returned. |
dtype |
numpy.dtype | type | None |
None |
Cast physical samples to a specific dtype (e.g., numpy.float32 for lower memory usage). Only valid when physical=True. |
tzinfo |
datetime.tzinfo | None |
None |
Timezone to apply to start_time and start_datetime on the header. None produces timezone-naive objects. |
eager_load_samples |
bool |
False |
When True, all signal samples are read into memory at open time. When False (default), samples are loaded lazily on first access. |
Returns
An EDFFile instance. Use it as a context manager to ensure the file handle is closed:
with read_edf("path/to/file.edf") as edf:
...
Exceptions
FileNotFoundError-- path does not existPermissionError-- no read permissionTypeError-- invalid source type, or invalidtzinfoValueError--dtypewithphysical=False, or malformed/truncated header
Data structures
EDFFile
The top-level container returned by read_edf(). Holds the parsed header, all signal instances, and annotations.
| Property | Type | Description |
|---|---|---|
header |
EDFHeader |
Parsed global header metadata. |
signals |
list[Signal] |
Non-annotation signal channels (lazy-loading). |
annotations |
list[Annotation] |
Parsed EDF+ annotations; empty for plain EDF. |
Supports indexing by label (edf["EEG Fp1"]) or integer position (edf[0]).
EDFHeader
A frozen dataclass containing all fields from the 256-byte global header.
| Field | Type | Description |
|---|---|---|
version |
str |
Always "0" for valid EDF files. |
variant |
"EDF" | "EDF+C" | "EDF+D" |
Detected file variant. |
start_date |
datetime.date |
Recording start date. |
start_time |
datetime.time |
Recording start time. |
start_datetime |
datetime.datetime |
Combined date and time. |
num_records |
int |
Number of data records. |
record_duration |
float |
Duration of each data record (seconds). |
num_signals |
int |
Total number of signals including annotation channels. |
header_bytes |
int |
Total header size in bytes. |
local_patient_id |
str |
Raw 80-byte patient identification field. |
local_recording_id |
str |
Raw 80-byte recording identification field. |
patient_code |
str | None |
Hospital/patient code. |
patient_sex |
str | None |
Sex subfield. |
patient_birthdate |
datetime.date | None |
Patient birthdate. |
patient_name |
str | None |
Patient name. |
recording_startdate |
datetime.date | None |
Recording start date from EDF+ subfield. |
recording_admin_code |
str | None |
Investigation/admin code. |
recording_technician |
str | None |
Technician/investigator code. |
recording_equipment |
str | None |
Equipment code. |
Signal
Represents a single channel with header metadata and sample data.
| Field | Type | Description |
|---|---|---|
label |
str |
Signal label (e.g., "EEG Fp1"). |
transducer_type |
str |
Transducer type string. |
physical_dimension |
str |
Physical unit (e.g., "uV"). |
physical_min |
float |
Minimum physical calibration value. |
physical_max |
float |
Maximum physical calibration value. |
digital_min |
int |
Minimum raw digital value. |
digital_max |
int |
Maximum raw digital value. |
prefiltering |
str |
Pre-filtering description. |
samples_per_record |
int |
Samples per data record for this channel. |
sample_rate |
float |
Sample rate in Hz. |
is_annotation |
bool |
True if this is an EDF Annotations channel. |
index |
int |
0-based position in the file's signal list. |
Accessing samples
signal.samples # Full sample array (loads lazily on first access)
signal.load(start, stop) # Load a slice by index, seconds, datetime, or timedelta
The load() and load_with_timestamps() methods accept flexible position arguments:
int-- sample indexfloat-- seconds from recording startdatetime.datetime-- absolute timedatetime.timedelta-- offset from recording start
You can also use onset/duration as alternatives to start/stop:
# These are equivalent:
signal.load(start=10.0, stop=15.0)
signal.load(onset=10.0, duration=5.0)
Annotation
A frozen dataclass representing a single EDF+ annotation from a TAL block.
| Field | Type | Description |
|---|---|---|
onset |
float |
Onset time in seconds from recording start. |
duration |
float | None |
Event duration in seconds, or None if unspecified. |
text |
str |
The annotation text. |
Examples
Read raw digital samples
with read_edf("recording.edf", physical=False) as edf:
raw = edf[0].samples # numpy int16 array
Use float32 for lower memory
import numpy as np
from edfplus import read_edf
with read_edf("recording.edf", dtype=np.float32) as edf:
samples = edf[0].samples # float32 instead of float64
Timezone-aware datetimes
from datetime import timezone, timedelta
from edfplus import read_edf
tz = timezone(timedelta(hours=1)) # CET
with read_edf("recording.edf", tzinfo=tz) as edf:
print(edf.header.start_datetime) # timezone-aware
Get samples with absolute timestamps
with read_edf("recording.edf") as edf:
signal = edf["EEG Fp1"]
samples, timestamps = signal.load_with_timestamps(
onset=0.0, duration=10.0, time_format="datetime"
)
# timestamps is a datetime64[us] array
Motivation
Several Python libraries exist for reading EDF files, but none adequately address the requirements of modern biosignal workflows — particularly long-duration clinical recordings, rigorous timestamp alignment for machine learning, and memory constraints at scale.
Discontinuous recordings (EDF+D)
The EDF+ specification defines two recording modes: continuous (EDF+C) and discontinuous (EDF+D). Discontinuous mode is essential for polysomnography (PSG) and other long-duration clinical studies — recordings that routinely exceed 10 hours — where pauses occur due to bathroom breaks, patient restlessness, impedance checks, or equipment adjustments. Some existing libraries (e.g., pyedflib) do not support EDF+D at all. Others silently concatenate data records across temporal gaps, producing a continuous array that misrepresents the actual timeline and corrupts any downstream analysis that depends on accurate sample timing.
edfplus correctly parses EDF+D time-stamped annotation lists (TALs) to identify discontinuities and exposes them through a straightforward API, preserving the true temporal structure of the recording.
Timezone-aware timestamps
The EDF+ specification is ambiguous regarding timezones: the startdate field is described as local time at the recording site, but no formal mechanism exists to encode the timezone itself. When patient data is anonymized and recording location is redacted — standard practice for research datasets — the timezone becomes unrecoverable from the file alone.
Existing libraries typically handle this by either assuming UTC or returning naive datetime objects, leaving the burden of timezone resolution on the caller. For machine learning and predictive modeling on biosignals, this is insufficient. Ground-truth labels, environmental covariates, and other time-aligned data sources must share a consistent temporal reference. Misaligned timeseries make supervised learning, forecasting, and cross-modal fusion impossible.
edfplus accepts an explicit tzinfo parameter at read time, attaching timezone information to all parsed datetimes and enabling correct absolute timestamp recovery for every sample in the recording.
Memory efficiency at scale
High-density PSG recordings generate large files. Consider a typical clinical configuration: 30 signals sampled at 512 Hz for 10 hours. Read into NumPy as the default float64 dtype:
30 × 512 × 36,000 seconds × 8 bytes ≈ 4.4 GB per recording
Once per-sample timestamps are computed alongside the signal data, the baseline memory footprint doubles to approximately 8.8 GB — just for the raw arrays. In practice, any ETL or ML pipeline holds the raw signals in memory simultaneously with normalized copies, windowed transforms, spectral features, and other derived representations. A single file can easily consume dozens of gigabytes across the stages of a feature engineering pipeline.
This is why edfplus provides fine-grained control over what gets loaded and how:
- Configurable dtype — pass
dtype=numpy.float32to halve memory usage while retaining physical scaling. - Raw digital access — set
physical=Falseto read the native int16 samples directly, reducing the signal footprint to approximately 1.1 GB for the same recording. - Selective signal loading — read individual signals or a subset rather than materializing all 30 channels at once. Process one signal at a time or pick only the channels your model requires.
- Lazy and streaming reads — samples are loaded on demand rather than eagerly materialized. Use
signal.load(start, stop)to read only the time segments your pipeline needs, preventing OOM errors in memory-constrained environments such as containerized ML pipelines, CI runners, and edge devices.
Roadmap
The next release will introduce configurable interpolation and downsampling strategies, giving users explicit control over the fidelity-vs-resource trade-off when working with multi-rate signal files. This includes streaming interpolation that operates on chunks rather than requiring the full signal in memory.
This is a deliberate design contrast with libraries (e.g., mne) that automatically upsample lower-rate signals to match the highest rate in the file — synthesizing samples that do not exist in the original recording and inflating memory usage unnecessarily. edfplus will instead allow users to choose a resampling strategy (or none at all), preserving data integrity by default and only introducing synthetic samples when explicitly requested.
Development
# Run tests
uv run pytest tests/
# Lint & format
uv run ruff check --fix
uv run ruff format
# Static type check
uv run ty check
License
See LICENSE for details.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file edfplus-0.0.1.tar.gz.
File metadata
- Download URL: edfplus-0.0.1.tar.gz
- Upload date:
- Size: 25.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5f1c3a5a23b4d57c4c3f096546ead97cd7b1e4f464aad7e3d1657742ce25454f
|
|
| MD5 |
8dca7dcd13996261dabfd7d989ed0f18
|
|
| BLAKE2b-256 |
bca3a2979429d87c3d159066f56ea3cee6f4b285a3822128481c58d2745b7b47
|
Provenance
The following attestation bundles were made for edfplus-0.0.1.tar.gz:
Publisher:
publish.yml on michaelwayman/edfplus
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
edfplus-0.0.1.tar.gz -
Subject digest:
5f1c3a5a23b4d57c4c3f096546ead97cd7b1e4f464aad7e3d1657742ce25454f - Sigstore transparency entry: 2383579837
- Sigstore integration time:
-
Permalink:
michaelwayman/edfplus@969aff55067bc801743c457866b0541ee60b391e -
Branch / Tag:
refs/tags/v0.0.1 - Owner: https://github.com/michaelwayman
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@969aff55067bc801743c457866b0541ee60b391e -
Trigger Event:
push
-
Statement type:
File details
Details for the file edfplus-0.0.1-py3-none-any.whl.
File metadata
- Download URL: edfplus-0.0.1-py3-none-any.whl
- Upload date:
- Size: 29.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ac843a9aaab8543ce2d9c898f54fc870e7c5eb624606b368c823b035e9ddd959
|
|
| MD5 |
0b194b194b36458e45a32b02a3f571e7
|
|
| BLAKE2b-256 |
e8a6a0b1ac24481035d73145ef56d62f8eacc89481be80fe38af1838d02baeb4
|
Provenance
The following attestation bundles were made for edfplus-0.0.1-py3-none-any.whl:
Publisher:
publish.yml on michaelwayman/edfplus
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
edfplus-0.0.1-py3-none-any.whl -
Subject digest:
ac843a9aaab8543ce2d9c898f54fc870e7c5eb624606b368c823b035e9ddd959 - Sigstore transparency entry: 2383580584
- Sigstore integration time:
-
Permalink:
michaelwayman/edfplus@969aff55067bc801743c457866b0541ee60b391e -
Branch / Tag:
refs/tags/v0.0.1 - Owner: https://github.com/michaelwayman
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@969aff55067bc801743c457866b0541ee60b391e -
Trigger Event:
push
-
Statement type: