Create NWB files with pynwb

Create NWB files with pynwb

Author
Dr. Atle E. Rimehaug

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:

  • /general for metadata
  • /acquisition for raw recordings
  • /processing for processed data
  • /analysis for results of analysis
  • /stimuli for stimuli used in experiment
  • /intervals/trials for trial information
  • /units for 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 BehavioralTimeSeries

Utility 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_region

Section 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 session
  • identifier: a globally unique string (a UUID is a safe default)
  • session_start_time: a timezone-aware datetime for 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_vis

Exercise: 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_aud

Exercise: 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.subject

Run the cell below to display the nwb_vis object and click on “subject” to check that it was added.

nwb_vis

Exercise: 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_vis

Exercise: 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:00

Exercise: 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.nwb

Exercise: 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.nwb

Section 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),
)
nwb

Example: 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_a

Run the cell below to display the nwb object and click on “devices” to see the probe that was just added.

nwb

Exercise: 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_b

Run the cell below to display the nwb object and click on “devices” to see the all probes that you have just added.

nwb

Exercise: 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_a

Run the cell below to display the nwb object and click on “electrode_groups” to see the group you just added.

nwb

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

nwb

Exercise: 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_b

Exercise: 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",
)
region
electrodes hdmf.common.table.DynamicTableRegion at 0x132228272070448
    Target table: electrodes pynwb.ecephys.ElectrodesTable at 0x132228272021712

Section 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)
nwb

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

nwb

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

nwb

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

nwb

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

nwb

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

nwb

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

nwb

Exercise: 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.nwb

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

nwb

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

nwb

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

nwb

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

nwb

Exercise: 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