Skip to content

DataTable

DataTable is a higher-level table with sorting, search, and pagination built in. It accepts list[dict] and pandas or polars DataFrames.


Quick Start

Live Preview
Name Email Status
Ada Lovelace ada@example.com Active
Alan Turing alan@example.com Invited
from faststrap import DataTable

DataTable(
    data,
    sortable=True,
    searchable=True,
    pagination=True,
    per_page=25,
    endpoint="/users",
)

Data Sources

DataTable accepts:

  • list[dict]
  • pandas DataFrame
  • polars DataFrame
DataTable(
    records,
    columns=["name", "email", "status"],
    header_map={"name": "User", "email": "Email"},
    include_index=False,
)

Enable sorting and search with flags. For interactive controls, provide endpoint or base_url so header links and search submits have a stable request target:

DataTable(
    data,
    sortable=True,
    searchable=True,
    search_placeholder="Search users...",
    search_param="q",
    search_debounce=300,
    endpoint="/users",
)

Restrict sortable columns with a list:

DataTable(data, sortable=["name", "email"])

If you want to control the current state (server-side mode), pass sort, direction, and search.


Pagination

Client-side pagination works for small sets when you render the full dataset locally. For server-side pagination, pass total_rows with endpoint or base_url.

DataTable(
    data,
    pagination=True,
    page=2,
    per_page=50,
    total_rows=1200,
)

Server-Side Contract

When you pass an endpoint, DataTable emits these query params:

  • sort
  • direction
  • page
  • per_page
  • q (or your search_param)
  • any filters you provide
DataTable(
    data,
    endpoint="/users",
    sortable=True,
    searchable=True,
    pagination=True,
    hx_target="#table",
    hx_swap="outerHTML",
    push_url=True,
)

Filters and Base URL

Use filters to preserve extra query params in pagination and sort links. Use base_url if you are not using HTMX. When you pass sort or search, DataTable applies that state to the rendered rows so the UI stays consistent with the current request.

DataTable(
    data,
    filters={"team": "ops"},
    base_url="/users",
)

Export Integration

Use the helper to reuse the table query state for exports:

from faststrap import DataTable, ExportButton

params = DataTable.export_params(
    sort="name",
    direction="asc",
    search="alice",
    filters={"team": "ops"},
)

ExportButton("Export CSV", endpoint="/export", export_format="csv", extra_params=params)

Theming and Layout

DataTable respects Bootstrap table styles:

  • striped=True
  • hover=True
  • bordered=True
  • responsive=True or responsive="md"

You can also pass table_cls and table_attrs to control the inner table element.


Accessibility

DataTable renders semantic table markup (<table>, <thead>, <tbody>). Use short, descriptive column names for screen readers.


Security Notes

  • Validate sort and direction server-side to avoid unsafe column injection.
  • Enforce per_page limits to prevent excessive responses.
  • Sanitize search strings before using them in SQL or ORM queries.

API Reference

faststrap.components.display.data_table.DataTable(data, *, columns=None, header_map=None, max_rows=None, include_index=False, empty_text='No data available', none_as='', striped=True, hover=True, bordered=False, responsive=True, sortable=False, sort=None, direction='asc', searchable=False, search=None, search_param='q', search_placeholder='Search...', search_debounce=300, pagination=False, page=1, per_page=25, total_rows=None, endpoint=None, base_url=None, filters=None, hx_target=None, hx_swap='outerHTML', push_url=False, table_id=None, table_cls=None, table_attrs=None, **kwargs)

DataTable with optional sorting, search, and pagination.

Parameters:

Name Type Description Default
data Any

List of dicts or pandas/polars DataFrame.

required
columns list[str] | None

Optional column order.

None
header_map dict[str, str] | None

Optional display name mapping for headers.

None
max_rows int | None

Optional max rows to render (pre-pagination).

None
include_index bool

Include index column for DataFrame or list data.

False
empty_text str

Text to display when no records exist.

'No data available'
none_as str

Substitute for None values.

''
striped bool

Enable zebra striping.

True
hover bool

Enable row hover styles.

True
bordered bool

Enable borders.

False
responsive bool | ResponsiveType

Wrap table in Bootstrap responsive container.

True
sortable bool | list[str]

True for all columns or a list of sortable columns.

False
sort str | None

Current sort column.

None
direction SortableDirection

Current sort direction.

'asc'
searchable bool

Render a search input.

False
search str | None

Current search value.

None
search_param str

Query param name for search.

'q'
search_placeholder str

Placeholder text for search input.

'Search...'
search_debounce int

Debounce (ms) for HTMX search input.

300
pagination bool

Enable pagination controls.

False
page int

Current page (1-indexed).

1
per_page int

Rows per page.

25
total_rows int | None

Total rows across all pages (server-side).

None
endpoint str | None

HTMX endpoint for server-side updates.

None
base_url str | None

Base URL for standard links (fallback).

None
filters dict[str, Any] | None

Extra query params to preserve in links.

None
hx_target str | None

HTMX target selector.

None
hx_swap str | None

HTMX swap strategy for links/search.

'outerHTML'
push_url bool

If True, enable hx-push-url for links.

False
table_id str | None

Explicit wrapper id (auto-generated if omitted).

None
table_cls str | None

Extra CSS classes for the table element.

None
table_attrs dict[str, Any] | None

Extra attributes applied to the table element.

None
**kwargs Any

Additional HTML attributes for the wrapper.

{}
Source code in src/faststrap/components/display/data_table.py
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
@register(category="display")
@beta
def DataTable(
    data: Any,
    *,
    columns: list[str] | None = None,
    header_map: dict[str, str] | None = None,
    max_rows: int | None = None,
    include_index: bool = False,
    empty_text: str = "No data available",
    none_as: str = "",
    striped: bool = True,
    hover: bool = True,
    bordered: bool = False,
    responsive: bool | ResponsiveType = True,
    sortable: bool | list[str] = False,
    sort: str | None = None,
    direction: SortableDirection = "asc",
    searchable: bool = False,
    search: str | None = None,
    search_param: str = "q",
    search_placeholder: str = "Search...",
    search_debounce: int = 300,
    pagination: bool = False,
    page: int = 1,
    per_page: int = 25,
    total_rows: int | None = None,
    endpoint: str | None = None,
    base_url: str | None = None,
    filters: dict[str, Any] | None = None,
    hx_target: str | None = None,
    hx_swap: str | None = "outerHTML",
    push_url: bool = False,
    table_id: str | None = None,
    table_cls: str | None = None,
    table_attrs: dict[str, Any] | None = None,
    **kwargs: Any,
) -> Div:
    """DataTable with optional sorting, search, and pagination.

    Args:
        data: List of dicts or pandas/polars DataFrame.
        columns: Optional column order.
        header_map: Optional display name mapping for headers.
        max_rows: Optional max rows to render (pre-pagination).
        include_index: Include index column for DataFrame or list data.
        empty_text: Text to display when no records exist.
        none_as: Substitute for None values.
        striped: Enable zebra striping.
        hover: Enable row hover styles.
        bordered: Enable borders.
        responsive: Wrap table in Bootstrap responsive container.
        sortable: True for all columns or a list of sortable columns.
        sort: Current sort column.
        direction: Current sort direction.
        searchable: Render a search input.
        search: Current search value.
        search_param: Query param name for search.
        search_placeholder: Placeholder text for search input.
        search_debounce: Debounce (ms) for HTMX search input.
        pagination: Enable pagination controls.
        page: Current page (1-indexed).
        per_page: Rows per page.
        total_rows: Total rows across all pages (server-side).
        endpoint: HTMX endpoint for server-side updates.
        base_url: Base URL for standard links (fallback).
        filters: Extra query params to preserve in links.
        hx_target: HTMX target selector.
        hx_swap: HTMX swap strategy for links/search.
        push_url: If True, enable hx-push-url for links.
        table_id: Explicit wrapper id (auto-generated if omitted).
        table_cls: Extra CSS classes for the table element.
        table_attrs: Extra attributes applied to the table element.
        **kwargs: Additional HTML attributes for the wrapper.
    """
    cfg = resolve_defaults(
        "DataTable",
        striped=striped,
        hover=hover,
        bordered=bordered,
        responsive=responsive,
        sortable=sortable,
        searchable=searchable,
        pagination=pagination,
        per_page=per_page,
        direction=direction,
        empty_text=empty_text,
        none_as=none_as,
    )

    c_striped = cfg.get("striped", striped)
    c_hover = cfg.get("hover", hover)
    c_bordered = cfg.get("bordered", bordered)
    c_responsive = cfg.get("responsive", responsive)
    c_sortable = cfg.get("sortable", sortable)
    c_searchable = cfg.get("searchable", searchable)
    c_pagination = cfg.get("pagination", pagination)
    c_per_page = cfg.get("per_page", per_page)
    c_direction = cfg.get("direction", direction)
    c_empty_text = cfg.get("empty_text", empty_text)
    c_none_as = cfg.get("none_as", none_as)

    if c_pagination and page < 1:
        msg = f"page must be >= 1, got {page}"
        raise ValueError(msg)
    if c_pagination and c_per_page < 1:
        msg = f"per_page must be >= 1, got {c_per_page}"
        raise ValueError(msg)

    resolved_columns, records, index_values = _normalize_table_data(
        data,
        columns=columns,
        max_rows=max_rows,
        include_index=include_index,
    )

    if isinstance(c_sortable, list):
        sortable_columns = [col for col in c_sortable if col in resolved_columns]
    elif c_sortable:
        sortable_columns = list(resolved_columns)
    else:
        sortable_columns = []

    if search:
        filtered_records: list[dict[str, Any]] = []
        filtered_index_values: list[str] | None = [] if index_values is not None else None
        for idx, row in enumerate(records):
            current_index = index_values[idx] if index_values is not None else None
            if _matches_search(
                row,
                columns=resolved_columns,
                query=search,
                index_value=current_index if include_index else None,
            ):
                filtered_records.append(row)
                if filtered_index_values is not None and current_index is not None:
                    filtered_index_values.append(current_index)
        records = filtered_records
        if filtered_index_values is not None:
            index_values = filtered_index_values

    if sort not in sortable_columns:
        sort = None
    elif endpoint is None:
        active_sort = cast(str, sort)
        indexed_records = list(enumerate(records))
        indexed_records.sort(
            key=lambda item: _sort_key(item[1].get(active_sort)),
            reverse=c_direction == "desc",
        )
        records = [record for _, record in indexed_records]
        if index_values is not None:
            index_values = [index_values[idx] for idx, _ in indexed_records]

    full_count = len(records)
    total_count = total_rows if total_rows is not None else full_count

    if c_pagination and endpoint is None and base_url is None:
        total_pages = math.ceil(total_count / c_per_page) if total_count else 1
        start = (page - 1) * c_per_page
        records = records[start : start + c_per_page]
        if index_values is not None:
            index_values = index_values[start : start + c_per_page]
    elif c_pagination:
        total_pages = math.ceil(total_count / c_per_page) if total_count else 1
    else:
        total_pages = 1

    visible_columns = list(resolved_columns)
    if include_index:
        visible_columns = ["index", *visible_columns]

    wrapper_id = kwargs.pop("id", None) or table_id
    if wrapper_id is None:
        wrapper_id = uniquify_id(
            _stable_table_id(
                columns=visible_columns,
                row_count=full_count,
                include_index=include_index,
                sortable=bool(sortable_columns),
                searchable=c_searchable,
                pagination=c_pagination,
            )
        )

    if hx_target is None:
        hx_target = f"#{wrapper_id}"

    link_base = endpoint or base_url

    base_params: dict[str, Any] = {}
    if filters:
        base_params.update(filters)
    if c_pagination:
        base_params["per_page"] = c_per_page
    if search:
        base_params[search_param] = search
    if sort:
        base_params["sort"] = sort
        base_params["direction"] = c_direction

    head_cells: list[Any] = []
    for col in visible_columns:
        header_label = (header_map or {}).get(col, col)
        if col in sortable_columns and link_base:
            current = sort == col
            next_dir: SortableDirection = "desc" if current and c_direction == "asc" else "asc"
            params = {**base_params, "sort": col, "direction": next_dir, "page": page}
            url = _build_url(link_base, params)
            link = A(
                header_label,
                (
                    Span(
                        "asc" if current and c_direction == "asc" else "desc",
                        cls="ms-1 text-muted small",
                    )
                    if current
                    else None
                ),
                cls="text-decoration-none",
                **_link_attrs(
                    url,
                    endpoint=endpoint,
                    hx_target=hx_target,
                    hx_swap=hx_swap,
                    push_url=push_url,
                ),
            )
            aria_sort = (
                "ascending"
                if current and c_direction == "asc"
                else "descending" if current else None
            )
            head_cells.append(TCell(link, header=True, scope="col", aria_sort=aria_sort))
        else:
            head_cells.append(TCell(header_label, header=True, scope="col"))

    thead = THead(TRow(*head_cells))

    if not records:
        tbody = TBody(
            TRow(
                TCell(
                    c_empty_text,
                    colspan=max(1, len(visible_columns)),
                    cls="text-center text-muted",
                )
            )
        )
    else:
        body_rows: list[Any] = []
        for idx, row in enumerate(records):
            row_cells: list[Any] = []
            if include_index and index_values is not None:
                row_cells.append(TCell(index_values[idx], header=True, scope="row"))

            for col in resolved_columns:
                value = row.get(col)
                rendered = c_none_as if value is None else str(value)
                row_cells.append(TCell(rendered))

            body_rows.append(TRow(*row_cells))

        tbody = TBody(*body_rows)

    table_kwargs = table_attrs.copy() if table_attrs else {}
    table_kwargs["cls"] = merge_classes(table_kwargs.get("cls"), table_cls)

    table = Table(
        thead,
        tbody,
        striped=c_striped,
        hover=c_hover,
        bordered=c_bordered,
        responsive=c_responsive,
        **table_kwargs,
    )

    parts: list[Any] = []

    if c_searchable:
        input_attrs: dict[str, Any] = {
            "type": "search",
            "name": search_param,
            "value": search or "",
            "placeholder": search_placeholder,
            "cls": "form-control",
            "autocomplete": "off",
        }
        if endpoint:
            input_attrs.update(
                {
                    "hx_get": link_base,
                    "hx_target": hx_target,
                    "hx_trigger": f"keyup changed delay:{search_debounce}ms",
                    "hx_swap": hx_swap,
                }
            )
            if push_url:
                input_attrs["hx_push_url"] = "true"
        hidden_inputs: list[Any] = []
        preserved_params = filters.copy() if filters else {}
        if sort:
            preserved_params["sort"] = sort
            preserved_params["direction"] = c_direction
        if c_pagination:
            preserved_params["per_page"] = c_per_page
            preserved_params["page"] = 1

        for key, value in preserved_params.items():
            normalized = _normalize_query_value(value)
            if normalized is None or str(key) == search_param:
                continue
            if isinstance(normalized, list):
                hidden_inputs.extend(
                    Input(type="hidden", name=str(key), value=item) for item in normalized
                )
            else:
                hidden_inputs.append(Input(type="hidden", name=str(key), value=normalized))

        search_form_attrs: dict[str, Any] = {"cls": "mb-3"}
        if link_base and not endpoint:
            search_form_attrs["method"] = "get"
            search_form_attrs["action"] = link_base

        search_form = Form(
            *hidden_inputs,
            Input(**input_attrs),
            **search_form_attrs,
        )
        parts.append(search_form)

    parts.append(table)

    if c_pagination and total_pages > 1:
        pager_links: list[Any] = []
        for page_num in range(1, total_pages + 1):
            active = page_num == page
            if link_base:
                params = {**base_params, "page": page_num}
                url = _build_url(link_base, params)
                link = A(
                    str(page_num),
                    cls="page-link",
                    **_link_attrs(
                        url,
                        endpoint=endpoint,
                        hx_target=hx_target,
                        hx_swap=hx_swap,
                        push_url=push_url,
                    ),
                )
            else:
                link = Span(str(page_num), cls="page-link")

            pager_links.append(
                Li(
                    link,
                    cls="page-item" + (" active" if active else ""),
                    aria_current="page" if active else None,
                )
            )

        pager = Nav(
            Ul(*pager_links, cls="pagination"),
            cls="mt-3",
            aria_label="Data table pagination",
        )
        parts.append(pager)

    wrapper_attrs: dict[str, Any] = {
        "id": wrapper_id,
        "cls": merge_classes("faststrap-data-table", kwargs.pop("cls", "")),
    }
    wrapper_attrs.update(convert_attrs(kwargs))

    return Div(*parts, **wrapper_attrs)