Create NWB files with pynwb
Author
Write and Append to NWB Files with pynwb
Neurodata Without Borders (NWB) is a community standard for sharing neurophysiology data. An NWB file is, on disk, an HDF5 file with a fixed schema. Below is a non-exhaustive list of the groups within an NWB file:
/generalfor metadata/acquisitionfor raw recordings/processingfor processed data/analysisfor results of analysis/stimulifor stimuli used in experiment/intervals/trialsfor trial information/unitsfor sorted units
In this session you’ll build a small NWB file from scratch using pynwb — adding the experiment and subject metadata, an extracellular signal with its electrode table, a trial table, a units table, and a couple of processing modules. At the end you’ll write the file to disk and read it back to confirm everything.
Setup
Import Libraries
import numpy as np
import matplotlib.pyplot as plt
from datetime import datetime, timezone
from uuid import uuid4
import pynwb
from pynwb import NWBFile, NWBHDF5IO, TimeSeries
from pynwb.file import Subject
from pynwb.ecephys import ElectricalSeries, LFP
from pynwb.behavior import BehavioralTimeSeriesUtility Functions
def create_electrode_table_region(nwb: pynwb.NWBFile, probe_name: str, shank_nr: int, area: str, num_electrodes: int):
device = nwb.create_device(name=probe_name, description="Synthetic 64-channel probe")
group = nwb.create_electrode_group(
name=f"shank{shank_nr}", description=f"single shank in {area}", location=area, device=device,
)
if not nwb.electrodes:
for i in range(num_electrodes):
nwb.add_electrode(x=0.0, y=0.0, z=float(i),location=area, group=group, filtering="none",)
region = nwb.create_electrode_table_region(region=list(range(num_electrodes)), description=f"all {area} electrodes",)
else:
num_prior_electrodes = len(nwb.electrodes)
for i in range(num_prior_electrodes, num_prior_electrodes + num_electrodes, 1):
nwb.add_electrode(x=0.0, y=0.0, z=float(i),location=area, group=group, filtering="none",)
region = nwb.create_electrode_table_region(region=list(range(num_prior_electrodes, num_prior_electrodes + num_electrodes, 1)), description=f"all {area} electrodes",)
return region
class utils:
create_electrode_table_region = create_electrode_table_regionSection 1: Creating an NWBFile and Subject Metadata
Background
Every NWB file starts from an NWBFile object. Three fields are required:
session_description: free-text description of the recording sessionidentifier: a globally unique string (a UUID is a safe default)session_start_time: a timezone-awaredatetimefor t=0 of the recording
Optional fields can be added to capture the rest of the experimental context, such as: experimenter, lab, institution, session_id, experiment_description, and a Subject object describing the recorded animal.
Exercises
| Code | Description |
|---|---|
NWBFile(session_description=, identifier=, session_start_time=) |
Construct an NWBFile |
str(uuid4()) |
Generate a unique identifier |
datetime(2026, 5, 1, 10, 0, tzinfo=timezone.utc) |
Build a timezone-aware datetime |
nwb.subject = Subject(subject_id=, species=, sex=, age=) |
Construct a subject and attach it to the file |
nwb.experimenter = 'Dr. X' |
Set the experimenter (an optional metadata field) |
nwb.lab = 'My Lab' |
Set the lab |
nwb.institution = 'My University' |
Set the institution |
nwb.session_id = 'vis-001' |
Set a session identifier |
nwb.experiment_description = '...' |
Set a one-sentence experiment description |
with NWBHDF5IO("my_session.nwb", "w") as io:io.write(nwb) |
Write to file |
with NWBHDF5IO("my_session.nwb", "r") as io:rnwb = io.read() |
Read the file |
Example: create an NWBFile with a description, a unique identifier, and a start time.
nwb_vis = NWBFile(
session_description="Visual stimulation in V1",
identifier=str(uuid4()),
session_start_time=datetime(2026, 5, 1, 10, 0, tzinfo=timezone.utc),
)
nwb_visExercise: create a second NWBFile (call it nwb_aud) for a different session — for example, “Auditory stimulation in A1”.
Solution
nwb_aud = NWBFile(
session_description="Auditory stimulation in A1",
identifier=str(uuid4()),
session_start_time=datetime(2026, 5, 2, 14, 30, tzinfo=timezone.utc),
)
nwb_audExercise: build a Subject for a male mouse aged 60 days and attach it to nwb_vis. You can fill the Subject fields as you like, but it is recommended to follow a convention, such as:
- age: ISO 8601 Duration format, e.g., “P90D” for 90 days old
- species: The formal Latin binomial nomenclature, e.g., “Mus musculus”, “Homo sapiens”
- sex: Single letter abbreviation, e.g., “F” (female), “M” (male), “U” (unknown), and “O” (other)
Solution
nwb_vis.subject = Subject(
subject_id="mouse-1",
species="Mus musculus",
sex="M",
age="P60D",
)
nwb_vis.subjectRun the cell below to display the nwb_vis object and click on “subject” to check that it was added.
nwb_visExercise: set experimenter, lab, and institution on nwb_vis to identify who recorded the session.
Solution
nwb_vis.experimenter = "Otto Normal"
nwb_vis.lab = "Anda Demo Lab"
nwb_vis.institution = "Demo University"
(nwb_vis.experimenter, nwb_vis.lab, nwb_vis.institution")('Otto Normal', 'Anda Demo Lab', 'Demo University')Run the cell below to display the nwb_vis object and see that the experimenter, lab, and institution metadata was added.
nwb_visExercise: print nwb_vis and look at the output to confirm the metadata you set above is attached to the file.
Solution
print(nwb_vis)root pynwb.file.NWBFile at 0x136269432562800
Fields:
experimenter: Otto Normal
file_create_date: [datetime.datetime(2026, 6, 2, 12, 44, 48, 448219, tzinfo=tzlocal())]
identifier: 998bafad-d6bb-4229-a484-6202b2782e1d
institution: Demo University
lab: Anda Demo Lab
session_description: Visual stimulation in V1
session_start_time: 2026-05-01 10:00:00+00:00
subject: subject pynwb.file.Subject at 0x136269432563072
Fields:
age: P60D
age__reference: birth
sex: M
species: Mus musculus
subject_id: mouse-42
timestamps_reference_time: 2026-05-01 10:00:00+00:00Exercise: write the nwb_vis file to vis_session.nwb using NWBHDF5IO.
Solution
with NWBHDF5IO("vis_session.nwb", "w") as io:
io.write(nwb_vis)
print("wrote vis_session.nwb")wrote vis_session.nwbExercise: write the nwb_aud file to aud_session.nwb using NWBHDF5IO.
Solution
with NWBHDF5IO("aud_session.nwb", "w") as io:
io.write(nwb_aud)
print("wrote aud_session.nwb")wrote aud_session.nwbSection 2: Adding Probes
Background
Before extracellular recordings can be stored, NWB needs to know where the signals came from. This is described by a small hierarchy of objects:
- a device: the physical probe or acquisition system
- an electrode group: a set of electrodes that belong together (e.g. one shank), attached to a device and given a brain
location - the electrode table: one row per electrode, recording its position and which electrode group the electrode belongs to
- an electrode table region: a reference to a subset of rows in the electrode table, used later to tell a signal which electrodes it was recorded from
Exercises
| Code | Description |
|---|---|
nwb.create_device(name="my-device-name", description="my description") |
Register a recording device |
group_x = nwb.create_electrode_group(name=, description=, location=, device=) |
Create a group of electrodes attached to a device and assign it to group_x |
nwb.add_electrode(x=, y=, z=, location=, group=, filtering=) |
Add a row to the electrode table |
nwb.electrodes.to_dataframe() |
Inspect the electrode table |
nwb.create_electrode_table_region(region=np.arange(5).tolist(), description='5 electrodes')) |
Create electrode table region for a set of 5 electrodes |
Run the cell below to create a new nwb object to be used in this section.
nwb = NWBFile(
session_description="Demo NWB file",
identifier=str(uuid4()),
session_start_time=datetime(2026, 5, 1, 10, 0, tzinfo=timezone.utc),
)
nwbExample: create a recording device on the file and assign it to a variable named device_a.
device_a = nwb.create_device(name="probe-A", description="Synthetic 32-channel probe")
device_aRun the cell below to display the nwb object and click on “devices” to see the probe that was just added.
nwbExercise: create a second device named 'probe-B' with a short description and assign it to a variable named device_b.
Solution
device_b = nwb.create_device(name="probe-B", description="Synthetic 64-channel probe")
device_bRun the cell below to display the nwb object and click on “devices” to see the all probes that you have just added.
nwbExercise: create an ElectrodeGroup named 'shank0', located in 'V1', with a description saying “single shank in V1”, attached to device_a. Assign it to a variable named group_a.
Hint: see nwb.create_electrode_group in code reference table.
Solution
group_a = nwb.create_electrode_group(
name="shank0",
description="single shank in V1",
location="V1",
device=device_a,
)
group_aRun the cell below to display the nwb object and click on “electrode_groups” to see the group you just added.
nwbExercise: Run the cell below to add four electrodes to the file’s electrode table, all belonging to group_a, placed them at x = y = 0, z = 0, 1, 2, 3, in 'V1', with filtering='none'.
for i in range(4):
nwb.add_electrode(
x=0.0, y=0.0, z=float(i),
location="V1", group=group_a, filtering="none",
)
nwb.electrodes.to_dataframe()| location | group | group_name | x | y | z | filtering | |
|---|---|---|---|---|---|---|---|
| id | |||||||
| 0 | V1 | shank0 pynwb.ecephys.ElectrodeGroup at 0x13222... | shank0 | 0.0 | 0.0 | 0.0 | none |
| 1 | V1 | shank0 pynwb.ecephys.ElectrodeGroup at 0x13222... | shank0 | 0.0 | 0.0 | 1.0 | none |
| 2 | V1 | shank0 pynwb.ecephys.ElectrodeGroup at 0x13222... | shank0 | 0.0 | 0.0 | 2.0 | none |
| 3 | V1 | shank0 pynwb.ecephys.ElectrodeGroup at 0x13222... | shank0 | 0.0 | 0.0 | 3.0 | none |
Run the cell below to display the nwb object and click on “electrodes” to see the table of electrodes that was added above.
nwbExercise: create a second ElectrodeGroup and assign it to a variable group_b. It should be named 'shank1', located in 'A1', attached to device_b.
Solution
group_b = nwb.create_electrode_group(
name="shank1",
description="single shank in A1",
location="A1",
device=device_b,
)
group_bExercise: add four electrodes to the file’s electrode table, all belonging to group_b (not group_a), placed them at x = 0, 1, 2, 3, y = z = 0, in 'A1', with filtering='none'.
Hint: Check how the V1 electrodes were added to the electrode table using nwb.add_electrode previously.
Solution
for i in range(4):
nwb.add_electrode(
x=float(i), y=0.0, z=0.0,
location="A1", group=group_b, filtering="none",
)
nwb.electrodes.to_dataframe()| location | group | group_name | x | y | z | filtering | |
|---|---|---|---|---|---|---|---|
| id | |||||||
| 0 | V1 | shank0 pynwb.ecephys.ElectrodeGroup at 0x13222... | shank0 | 0.0 | 0.0 | 0.0 | none |
| 1 | V1 | shank0 pynwb.ecephys.ElectrodeGroup at 0x13222... | shank0 | 0.0 | 0.0 | 1.0 | none |
| 2 | V1 | shank0 pynwb.ecephys.ElectrodeGroup at 0x13222... | shank0 | 0.0 | 0.0 | 2.0 | none |
| 3 | V1 | shank0 pynwb.ecephys.ElectrodeGroup at 0x13222... | shank0 | 0.0 | 0.0 | 3.0 | none |
| 4 | A1 | shank1 pynwb.ecephys.ElectrodeGroup at 0x13222... | shank1 | 0.0 | 0.0 | 0.0 | none |
| 5 | A1 | shank1 pynwb.ecephys.ElectrodeGroup at 0x13222... | shank1 | 1.0 | 0.0 | 0.0 | none |
| 6 | A1 | shank1 pynwb.ecephys.ElectrodeGroup at 0x13222... | shank1 | 2.0 | 0.0 | 0.0 | none |
| 7 | A1 | shank1 pynwb.ecephys.ElectrodeGroup at 0x13222... | shank1 | 3.0 | 0.0 | 0.0 | none |
Exercise: Build an electrode_table_region referencing all 8 electrodes you just added.
Solution
region = nwb.create_electrode_table_region(
region=np.arange(8).tolist(), description="all 8 electrodes",
)
regionelectrodes hdmf.common.table.DynamicTableRegion at 0x132228272070448
Target table: electrodes pynwb.ecephys.ElectrodesTable at 0x132228272021712Section 3: Add Acquisition and Processed Data
Background
NWB keeps a clear separation between data as it came off the instrument and data you computed from it.
-
Raw data is stored exactly as acquired, together with a reference back to the electrodes it came from, so the original recording is always preserved untouched.
-
Processed data is anything derived from the raw signal — filtered LFP, spike-sorting output, behavioural variables such as running speed. These are grouped by topic, with one module per kind of data (e.g. one for electrophysiology-derived signals, one for behaviour). This keeps the file organised and makes clear what is a primary recording and what is an interpretation of it.
In this section you store the raw recordings for each region, then add the processed data (LFP and a behavioral signal) into their respective modules.
Exercises
| Code | Description |
|---|---|
ElectricalSeries(name=, data=, electrodes=, rate=, starting_time=) |
Construct an extracellular voltage series |
nwb.add_acquisition(es) |
Attach a raw acquisition object to the file |
nwb.create_processing_module(name=, description=) |
Create a processing module |
TimeSeries(name=, data=, unit=, rate=, starting_time=) |
Generic time series |
BehavioralTimeSeries(time_series=, name=) |
Behaviour data interface |
LFP(electrical_series=) |
LFP data interface |
nwb.processing[name].add(obj) |
Add a data interface to a module |
with NWBHDF5IO("my_session.nwb", "w") as io:io.write(nwb) |
Write to file |
with NWBHDF5IO("my_session.nwb", "r") as io:rnwb = io.read() |
Read the file |
Run the cell below to create a new nwb object and a region of electrodes used in this section.
nwb = NWBFile(
session_description="Demo NWB file",
identifier=str(uuid4()),
session_start_time=datetime(2026, 5, 1, 10, 0, tzinfo=timezone.utc),
)
region_V1 = utils.create_electrode_table_region(nwb, probe_name='probe-A', shank_nr = 0, area = 'V1', num_electrodes=4)
region_A1 = utils.create_electrode_table_region(nwb, probe_name='probe-B', shank_nr = 1, area = 'A1', num_electrodes=4)nwbExample: for the random data below, construct an ElectricalSeries with the name raw_recording_V1, electrodes set to region_V1, a sampling rate of 30000.0 Hz, and starting_time=0.0. Add it to nwb.acquisition.
data = np.random.normal(size = (60000, 4))
data.shape, data.dtype
es = ElectricalSeries(
name="raw_recording_V1",
data=data,
electrodes=region_V1,
rate=30000.0,
starting_time=0.0,
)
nwb.add_acquisition(es)Exercise: Run the cell below to display the nwb object and click on acquisition to see the data that was added.
nwbExercise: for the random data below, construct an ElectricalSeries with the name raw_recording_A1, region_A1 for electrodes, a sampling rate of 30000.0 Hz, and starting_time=0.0. Add it to nwb.acquisition.
Solution
data = np.random.normal(size = (60000, 4))
es = ElectricalSeries(
name="raw_recording_A1",
data=data,
electrodes=region_A1,
rate=30000.0,
starting_time=0.0,
)
nwb.add_acquisition(es)Run the cell below to display the nwb object and click on “processing” to see the added behavior module.
nwbExample: create a processing module named 'ecephys' for electrophysiology-derived data.
nwb.create_processing_module(
name="ecephysV1", description="Derived ecephys V1",
)Run the cell below to display the nwb object and click on “processing” to see the added ecephys module.
nwbRun the cell below to create an ElectricalSeries object for LFP from the four channels in V1.
es_lfp = ElectricalSeries(
name="lfp", data=np.random.normal(size = (2000, 4)), electrodes=region_V1,
rate=1000.0, starting_time=0.0,
)Exercise: add the LFP Electrical series from the V1 electrodes to the ecephysV1 module.
Solution
nwb.processing['ecephysV1'].add(LFP(electrical_series=es_lfp))Run the cell below to display the nwb object and click on “processing” to see the added lfp time series.
nwbExercise: create a processing module named 'ecephysA1' for electrophysiology-derived data. Hint: See example above where ecephysV1 was added.
Solution
nwb.create_processing_module(
name="ecephysA1", description="Derived ecephys A1",
)Run the cell below to create an ElectricalSeries object for LFP from the four channels in A1.
es_lfp = ElectricalSeries(
name="lfp", data=np.random.normal(size = (2000, 4)), electrodes=region_A1,
rate=1000.0, starting_time=0.0,
)Exercise: add the LFP Electrical series from the A1 electrodes to the ecephys module.
Solution
nwb.processing['ecephysA1'].add(LFP(electrical_series=es_lfp))Run the cell below to display the nwb object and click on “processing” to see the added lfp time series.
nwbExercise: create a 'behavior' processing module. Hint: Look at how the ecephysV1 processing module was created.
Solution
nwb.create_processing_module(
name="behavior", description="Behavioural data",
)Run the cell below to build a TimeSeries of synthetic running speed (200 samples at 10 Hz, in 'cm/s') and put it in a BehavioralTimeSeries with the name field name = 'behavior-time-series'.
running_speed = TimeSeries(
name="running_speed",
data=np.random.uniform(0, 30, size=200),
unit="cm/s",
rate=10.0,
starting_time=0.0,
)
bts = BehavioralTimeSeries(time_series=running_speed, name="behavior-time-series")Exercise: add the behavioral time series containing the running speed the behavior module.
Solution
nwb.processing["behavior"].add(bts)Run the cell below to display the nwb object and click on “processing” to see the added behavior time series.
nwbExercise: write the file to my_session.nwb using NWBHDF5IO.
Solution
with NWBHDF5IO("my_session.nwb", "w") as io:
io.write(nwb)
print("wrote my_session.nwb")wrote my_session.nwbSection 4: Writing Tabular Data with DynamicTables: Trials and Spike Units
Background
Beyond the recorded signals, an NWB file also captures the structure of the experiment and the results of analysing it. The trials table divides the session into labeled time intervals, each with a start and stop, plus any extra information such as the stimulus shown or the choice made, turning a continuous recording into the events most analyses are built around. The units table holds the spiking activity of individual neurons found by spike sorting, essentially a list of firing times that can be aligned to those trials.
In this section you build a small trial table with a stimulus label and add a couple of sorted units with synthetic spike times.
Exercises
| Code | Description |
|---|---|
nwb.add_trial_column(name=, description=) |
Define a new trial column (before adding rows) |
nwb.add_trial(start_time=, stop_time=, ...) |
Add a trial row |
nwb.trials.to_dataframe() |
Inspect the trial table |
nwb.add_unit(spike_times=...) |
Add a sorted unit |
nwb.units.to_dataframe() |
Inspect the units table |
len(nwb.units) |
Number of units |
Run the cell below to create a new nwb object used in this section.
nwb = NWBFile(
session_description="Demo NWB file",
identifier=str(uuid4()),
session_start_time=datetime(2026, 5, 1, 10, 0, tzinfo=timezone.utc),
)Example: add a stim_type column to the trial table and add one trial.
nwb.add_trial_column(name="stim_type", description="Stimulus identifier")
nwb.add_trial(start_time=0.0, stop_time=0.3, stim_type="A")Run the cell below to display the nwb object and click on “trials” to see the trial table that was just added.
nwbExercise: add a second trial with start_time=0.5, stop_time=0.8, stim_type='B'.
Solution
nwb.add_trial(start_time=0.5, stop_time=0.8, stim_type="B")Exercise: add a third trial with start_time=1.0, stop_time=1.3, stim_type='A'.
Solution
nwb.add_trial(start_time=1.0, stop_time=1.3, stim_type="A")Run the cell below to display the nwb object and click on “trials” to see that two new trials were added to the trial table.
nwbExercise: add a unit to nwb.units with synthetic spike times. Use np.sort(np.random.uniform(0, 2, size=4)) to create a unit with 4 random spikes in a two-second window sorted from the earliest to the latest.
Solution
nwb.add_unit(spike_times=np.sort(np.random.uniform(0, 2, size=4)))Run the cell below to display the nwb object and click on “units” to see the added unit.
nwbExercise: add another unit to nwb.units with synthetic spike times. Use np.sort(np.random.uniform(0, 2, size=8)) to create a unit with 8 random spikes within a two-second window sorted from the earliest to the latest.
Solution
nwb.add_unit(spike_times=np.sort(np.random.uniform(0, 2, size=8)))Run the cell below to display the nwb object and click on “units” to see all added units.
nwbExercise: Add the timestamps of the 5 spike trains in all_spike_times generated below to the nwb file.
all_spike_times = np.sort(np.random.uniform(0,2, size = (5, 8)), axis = 1)Solution
for i in range(5):
nwb.add_unit(spike_times = all_spike_times[i])Run the cell below to display the nwb object and click on “units” to see all added units.
nwb