Many Benchling tables — visual walkthrough

One workbook, many managed Benchling assay-result tables — spread across several sheets, or stacked vertically on one sheet — all stood up from a single Python plan dict, then filled in bulk. This page mirrors the guide: the two layouts and the one rule, the dict that drives them, and the multi-paste that fills them.

Table header Live data Stacked-below neighbour Empty / gap cell

1 · Two layouts, one rule SHEETS STACKED

A managed Benchling table is a merged title band, a header row, and a set of named ranges bracketed by <name>_table_start / <name>_table_end bookmarks. Many such tables coexist in one of two layouts.

(a) One table per SHEET

Potency tab

A
B
1
Potency Assay
2
sample_id
ec50
3
VS0001
0.42
4
VS0002
0.51

Identity tab

A
B
C
1
Identity Assay
2
sample_id
method
call
3
VS0001
LC-MS
Pass
4
VS0002
LC-MS
Pass

Purity tab

A
B
C
1
Purity Assay
2
sample_id
peak
pct_purity
3
VS0001
1
98.7
4
VS0002
1
97.2

Each tab is its own canvas — tabs name themselves after the assay, and there is no row-collision risk between tables. This is the default; when in doubt, one table per sheet.

(b) Two tables STACKED on one sheet

Identity tab — Identity over Purity

A
B
C
1
Identity Assay
2
sample_id
method
call
3
VS0001
LC-MS
Pass
4
VS0002
LC-MS
Pass
⋮ table_gap = 2 blank rows ⋮
7
Purity Assay
8
sample_id
peak
pct_purity
9
VS0001
1
98.7
10
VS0002
1
97.2

Both tables start at column A and share the same columns — that is fine. The Purity table (the stacked-below NEIGHBOUR) owns its own row cursor, landing table_gap rows below Identity's _table_end.

The lane rule: two managed tables on the same sheet must occupy disjoint rows if they are in the same lane (overlapping column bands). Tables in disjoint column bands are independent lanes and may share rows — side-by-side, L-shaped, and T-shaped layouts across separate column bands are supported. A row overlap within a lane raises SideBySideNotSupportedError at paste time. Tables on different sheets have no constraints.

2 · Dict-driven layout {sheet : [schema_id]}

The function's second argument is a dict[sheet_name, list[schema_id]] — each sheet maps to the ordered list of assaysch_… ids placed on it. Two ids under one sheet stack on it, in list order. This is the only calling shape: there is no positional sheet + list form and no flat {schema_id : sheet} dict.

Native mapping — {sheet : [schema_ids]}
sheet
schema_ids
·
Potency
[assaysch_a]
·
Identity
[assaysch_b, assaysch_c]

Identity maps to a two-id list, so assaysch_b and assaysch_c stack there in list order; Potency holds a one-id list, so assaysch_a gets that tab to itself.

Resulting multi-sheet layout

Potency tab

A
B
1
Potency Assay
2
sample_id
ec50

Identity tab

A
B
C
1
Identity Assay
2
sample_id
method
call
⋮ table_gap ⋮
5
Purity Assay
6
sample_id
peak
pct_purity

Pass the plan dict straight in

The plan dict is the argument — pass it in a single call, no loop and no flattening. It reads exactly how you think about the layout: "this sheet holds these tables."

# Plan keyed by sheet — this IS the native schema_map argument
tables = {
    "Potency":  ["assaysch_a"],               # one table on the Potency tab
    "Identity": ["assaysch_b", "assaysch_c"], # two stacked on Identity, in list order
}

results = initialise_table_benchling(wb, tables, ctx=ctx)

The sheet is created if absent and reused if present. Each id is resolved against Benchling (assaysch_… ids only — name lookup is not supported). results is a flat list[SchemaInitResult] — one typed object per schema across all sheets — each exposing the attributes schema_name, display_name, warehouse_name, range_name, sheet_name, columns (a tuple[ColumnInfo, …]), action ("created" or "reinitialised"), and — on reinit — diff.

When you still want a loop: a single call shares one column anchor across every sheet. To set a different anchor column per sheet, or to catch errors sheet-by-sheet, loop and call once per sheet — each entry is itself a valid one-entry schema_map: initialise_table_benchling(wb, {sheet: ids}, ctx=ctx, column=…).

3 · Multi-paste — fill several tables FILLED

paste_df_to_named_range writes a DataFrame into a managed table addressed by its named-range token (the schema warehouse name, snake_case). It has a built-in list mode: pass a list of DataFrames and a matching list of tokens — the lengths must match; it zips and pastes each pair (a convenience loop, no batch magic).

List mode — many tables on ONE sheet

paste_df_to_named_range(
    wb,
    [identity_df, purity_df],            # list of DataFrames
    ["identity_assay", "purity_assay"],  # matching list of range tokens
    scope_sheet="Identity",              # both tables live on this one tab
)
Identity tab — both tables filled
A
B
C
1
Identity Assay
2
sample_id
method
call
3
VS0001
LC-MS
Pass
4
VS0002
LC-MS
Pass
⋮ table_gap ⋮
7
Purity Assay
8
sample_id
peak
pct_purity
9
VS0001
1
98.7
10
VS0002
1
97.2

In list mode scope_sheet is a single value forwarded unchanged to every paste — so list mode is the right tool only when all the target tables live on the same sheet.

Loop — tables across MULTIPLE sheets
# Each table needs its own scope_sheet → loop the single-paste form.
to_write = {
    ("potency_assay",  "Potency"):  potency_df,
    ("identity_assay", "Identity"): identity_df,
    ("purity_assay",   "Identity"): purity_df,
}

for (token, sheet), df in to_write.items():
    paste_df_to_named_range(
        wb, df, token, scope_sheet=sheet,
    )

Potency tab — filled by single paste

A
B
1
Potency Assay
2
sample_id
ec50
3
VS0001
0.42
4
VS0002
0.51

Column order is matched per table. Each paste validates your DataFrame's columns against that table's existing headers, position by position — data is written positionally (df.values), so a reordered frame would silently misalign. A mismatch raises ColumnOrderMismatchError before anything is written. Match against the warehouse-name token or the header display text (case- / whitespace-insensitive). If Benchling added columns, re-initialise that table first so its headers exist, then paste the wider frame.

List mode shares one scope_sheet. Because the single forwarded value applies to every pair, never put tables from different sheets in one list-mode call — loop the single-paste form for multi-sheet writes. The DataFrame and token lists must also be the same length, or list mode raises ValueError. The index is never written (only df.values); a zero-row frame raises EmptyDataError.