CTkDataTable
A fast, virtualized data table for CustomTkinter. Display thousands of records from databases, APIs, or Python lists — without creating one Tkinter widget per cell.
Installation
Install CTkDataTable and CustomTkinter in your Python environment:
pip install --upgrade customtkinter CTkDataTable
Version 0.2.0 requires Python 3.11+. If you use a virtual environment, run the install command with that environment's Python so your app loads the same package version.
Then import the widget at the top of your file:
import customtkinter as ctk
from CTkDataTable import CTkDataTable
Optional public helpers — import only what you need:
from CTkDataTable import (
BadgeStyle,
Column,
TableAction,
TableColumn,
TableRowEvent,
TableStyle,
rows_from_cursor,
)
Release 0.2.0 Highlights
Release 0.2.0 improves table sizing, scaling, and packaging metadata.
- Added
column_width_modewith"fixed"and"fill"layouts. - Column widths, row heights, style dimensions, and resize hit areas now render correctly with CustomTkinter widget scaling.
- Added
set_column_width_mode()andget_column_width_mode(). - Resizable columns now convert drag distance from canvas pixels back to logical column width.
- Project metadata now points to the current GitHub repository and hosted documentation site.
If column_width_mode raises an unsupported argument error, that project's environment is still on CTkDataTable 0.1.0. Run python -m pip install --upgrade CTkDataTable inside the same virtual environment used to start your app.
Quick Start
Use the table in three steps:
- Define columns — each column needs a
keythat exactly matches your row dictionaries. - Provide rows — a list of dictionaries (or any mapping-like objects).
- Place the table with
grid(),pack(), orplace().
column["key"] must match the row dictionary key exactly — same spelling, same capitalization. customer_name and customerName are different keys and will produce blank cells.
import customtkinter as ctk
from CTkDataTable import CTkDataTable
app = ctk.CTk()
app.title("Customers")
app.geometry("760x420")
app.grid_columnconfigure(0, weight=1)
app.grid_rowconfigure(0, weight=1)
columns = [
{"key": "id", "title": "ID", "width": 80, "type": "number"},
{"key": "name", "title": "Customer", "width": 220},
{"key": "status", "title": "Status", "width": 140, "type": "badge"},
]
rows = [
{"id": 1, "name": "Northwind Components", "status": "Open"},
{"id": 2, "name": "Meridian Foods", "status": "Closed"},
{"id": 3, "name": "Blue Ridge Logistics", "status": "In Review"},
]
table = CTkDataTable(
app,
columns=columns,
data=rows,
column_width_mode="fill",
resizable_columns=True,
)
table.grid(row=0, column=0, sticky="nsew", padx=16, pady=16)
app.mainloop()
Tutorial 1: Static Data Display
Use this pattern when you already have a complete list of records. Pass column definitions and row data to the constructor — the table renders immediately.
The Quick Start example above covers this pattern. Key points to remember:
- Call
app.grid_columnconfigure(0, weight=1)andapp.grid_rowconfigure(0, weight=1)so the table expands to fill the window. - Use
sticky="nsew"intable.grid(). - Pass
data=rowsto the constructor, or calltable.set_data(rows)at any time later.
Tutorial 2: Real-Time Updates
Use add_row(), update_row_where(), and delete_row_by_key() when rows change after the table is already on screen.
import customtkinter as ctk
from CTkDataTable import CTkDataTable
app = ctk.CTk()
app.title("Live Orders")
app.geometry("820x420")
app.grid_columnconfigure(0, weight=1)
app.grid_rowconfigure(1, weight=1)
columns = [
{"key": "id", "title": "Order", "width": 100},
{"key": "customer", "title": "Customer", "width": 220},
{"key": "status", "title": "Status", "width": 140, "type": "badge"},
{"key": "amount", "title": "Amount", "width": 120, "type": "currency"},
]
rows = [
{"id": "SO-1001", "customer": "Northwind Components", "status": "Open", "amount": 1250},
{"id": "SO-1002", "customer": "Meridian Foods", "status": "Open", "amount": 890},
]
table = CTkDataTable(app, columns=columns, data=rows)
table.grid(row=1, column=0, sticky="nsew", padx=16, pady=(0, 16))
def add_order():
table.add_row(
{"id": "SO-1003", "customer": "Blue Ridge Logistics", "status": "Open", "amount": 1425}
)
def mark_shipped():
table.update_row_where(
"id",
"SO-1001",
{"id": "SO-1001", "customer": "Northwind Components", "status": "Shipped", "amount": 1250},
)
def remove_order():
table.delete_row_by_key("id", "SO-1002")
toolbar = ctk.CTkFrame(app, corner_radius=0)
toolbar.grid(row=0, column=0, sticky="ew", padx=16, pady=(16, 8))
ctk.CTkButton(toolbar, text="Add", command=add_order ).grid(row=0, column=0, padx=6, pady=8)
ctk.CTkButton(toolbar, text="Ship SO-1001", command=mark_shipped).grid(row=0, column=1, padx=6, pady=8)
ctk.CTkButton(toolbar, text="Remove SO-1002", command=remove_order).grid(row=0, column=2, padx=6, pady=8)
app.mainloop()
Tutorial 3: Interactive Table
Combine sorting, searching, column filters, and row selection. Users can click sortable column headers; your code can call sort_by(), search(), and set_column_filter() at any time.
import customtkinter as ctk
from CTkDataTable import CTkDataTable, TableRowEvent
app = ctk.CTk()
app.title("Interactive Table")
app.geometry("900x500")
app.grid_columnconfigure(0, weight=1)
app.grid_rowconfigure(2, weight=1)
status_lbl = ctk.CTkLabel(app, text="No selection", anchor="w")
status_lbl.grid(row=0, column=0, sticky="ew", padx=16, pady=(16, 4))
search = ctk.CTkEntry(app, placeholder_text="Search visible columns")
search.grid(row=1, column=0, sticky="ew", padx=16, pady=(4, 8))
columns = [
{"key": "id", "title": "ID", "width": 80, "type": "number"},
{"key": "name", "title": "Customer", "width": 220},
{"key": "status", "title": "Status", "width": 140, "type": "badge"},
{"key": "amount", "title": "Amount", "width": 120, "type": "currency"},
]
rows = [
{"id": 1, "name": "Northwind Components", "status": "Open", "amount": 1250},
{"id": 2, "name": "Meridian Foods", "status": "Closed", "amount": 890},
{"id": 3, "name": "Blue Ridge Logistics", "status": "Open", "amount": 1425},
]
def show_selected(event: TableRowEvent) -> None:
status_lbl.configure(
text=f"Row {event.source_index}: {event.row['name']} ({event.row['status']})"
)
def report_sort(column_key: str, ascending: bool) -> None:
direction = "asc" if ascending else "desc"
status_lbl.configure(text=f"Sorted by '{column_key}' {direction}")
table = CTkDataTable(
app,
columns=columns,
data=rows,
multi_select=True,
on_row_click=show_selected,
on_sort=report_sort,
)
table.grid(row=2, column=0, sticky="nsew", padx=16, pady=(0, 16))
# Live search as the user types
search.bind("<KeyRelease>", lambda _e: table.search(search.get()))
# Pre-apply a filter and sort
table.set_column_filter("status", {"type": "equals", "value": "Open"})
table.sort_by("amount", ascending=False)
app.mainloop()
Row Data Sources
Rows are normalized to dictionaries internally. The table accepts several source formats:
| Row source | How to use it |
|---|---|
dict | Pass directly in data, set_data(), add_row(), or add_rows(). |
| Any mapping object | Pass directly — anything that behaves like a dictionary works. |
sqlite3.Row | Set connection.row_factory = sqlite3.Row, then pass fetched rows directly. |
| SQLAlchemy result rows | Pass fetched result rows directly (uses _mapping). |
| PostgreSQL dictionary rows | Use dict_row factory (psycopg 3) or RealDictCursor (psycopg 2). |
| Plain DB-API tuple rows | Convert with rows_from_cursor(cursor). |
SQLite Rows
Set row_factory = sqlite3.Row before executing any queries. This enables key-based access so the table can read column values by name.
import sqlite3
connection = sqlite3.connect("customers.db")
connection.row_factory = sqlite3.Row # required for key-based access
rows = connection.execute(
"SELECT id, name, status FROM customers ORDER BY name"
).fetchall()
table.set_data(rows)
DB-API Cursor Rows
Plain tuple rows don't contain column names. Use rows_from_cursor() to convert them using cursor.description.
from CTkDataTable import rows_from_cursor
cursor.execute("SELECT id, name, status FROM customers ORDER BY name")
table.set_data(rows_from_cursor(cursor))
SQLAlchemy Rows
from sqlalchemy import text
with engine.connect() as conn:
result = conn.execute(
text("SELECT id, name, status FROM customers ORDER BY name")
)
table.set_data(result.fetchall())
Use SQL aliases when database column names differ from your table keys:
SELECT customer_id AS id, customer_name AS name, order_status AS status
FROM customers;
Column Definitions
Columns can be defined three ways. All three forms are interchangeable and normalize to TableColumn internally.
columns = [
{"key": "id", "title": "ID", "width": 80, "type": "number"},
{"key": "name", "title": "Customer", "width": 220},
]
from CTkDataTable import TableColumn
columns = [
TableColumn(key="id", title="ID", width=80, type="number"),
TableColumn(key="name", title="Customer", width=220),
]
from CTkDataTable import Column
columns = [
Column("id").title("ID").width(80).number(),
Column("name").title("Customer").width(220).text(),
Column("status").title("Status").width(140).badge(
colors={"Open": "#22c55e", "Closed": "#64748b"},
fallback_color="#94a3b8",
),
]
Common Column Options
| Option | Default | Description |
|---|---|---|
key | Required | Row dictionary field name. Must match exactly. |
title | Title-cased key | Column header label. |
width | 140 | Preferred column width in logical pixels. |
align | Type-dependent | "left", "center", or "right". |
visible | True | Hidden columns are not rendered or searched. |
sortable | True | Whether header clicks sort this column. |
formatter | None | Callable (value, row) -> str. Runs before built-in type formatting. |
metadata | {} | App-specific data stored with the column. Not rendered. |
Default alignment by column type
| Type(s) | Default alignment |
|---|---|
number, percentage, currency | right |
checkbox, action, progress | center |
| All other types | left |
Column Type: text
The default type. Displays row values as plain text and sorts case-insensitively.
{"key": "name", "title": "Customer", "width": 220, "type": "text"}
| Option | Default | Description |
|---|---|---|
type | "text" | Plain text display. Also the default when type is omitted. |
formatter | None | Callable (value, row) -> str for custom display text. |
Column Type: number
Right-aligns values by default, sorts numerically, and accepts an optional format string.
{
"key": "quantity",
"title": "Quantity",
"width": 140,
"type": "number",
"number_format": "{:,.0f}", # "12,400"
}
| Option | Default | Description |
|---|---|---|
number_format | None | Format string using .format(number), or callable (value) -> str. |
Column Type: percentage
Right-aligns values, sorts numerically, and formats with a percent sign. For ratio values such as 0.184, set percentage_multiplier to 100.
{
"key": "margin",
"title": "Margin",
"width": 150,
"type": "percentage",
"percentage_format": "{value:.1f}%",
# "percentage_multiplier": 100, # use this for 0–1 ratio values
}
| Option | Default | Description |
|---|---|---|
percentage_format | "{value:.0f}%" | Format string using value, raw_value, and multiplier. |
percentage_multiplier | 1.0 | Multiplied by the raw value before formatting. Use 100 for 0–1 ratio values. |
Column Type: currency
Right-aligns values, sorts numerically, and formats money. Separate format strings for positive and negative values.
{
"key": "amount",
"title": "Amount",
"width": 150,
"type": "currency",
"currency_symbol": "GBP ",
"currency_format": "{symbol}{value:,.2f}",
"currency_negative_format": "({symbol}{value:,.2f})",
}
| Option | Default | Description |
|---|---|---|
currency_symbol | "$" | Symbol or prefix string used in format strings. |
currency_format | "{symbol}{value:,.2f}" | Format string for zero and positive values. Variables: symbol, value, signed_value. |
currency_negative_format | "-{symbol}{value:,.2f}" | Format string for negative values. value is the absolute amount. |
Column Type: date
Accepts datetime.date, datetime.datetime, and ISO date strings. Sorts by parsed date values.
from datetime import date
columns = [{"key": "due", "title": "Due Date", "width": 140, "type": "date", "date_format": "%d %b %Y"}]
rows = [{"due": date(2026, 6, 12)}, {"due": "2026-06-18"}] # both accepted
| Option | Default | Description |
|---|---|---|
date_format | "%Y-%m-%d" | strftime format string for display. |
Column Type: datetime
Accepts datetime.datetime, datetime.date, and ISO datetime strings. Sorts by parsed datetime values.
from datetime import datetime
columns = [{"key": "updated", "title": "Updated", "width": 160, "type": "datetime", "datetime_format": "%d %b %H:%M"}]
rows = [{"updated": datetime(2026, 6, 4, 15, 45)}, {"updated": "2026-06-05T09:15:00"}]
| Option | Default | Description |
|---|---|---|
datetime_format | "%Y-%m-%d %H:%M" | strftime format string for display. |
Column Type: badge
Draws a colored rounded label for status-like values. Map each text value to a fill color using badge_colors.
{
"key": "status",
"title": "Status",
"width": 150,
"type": "badge",
"badge_colors": {
"Open": "#22c55e",
"Closed": "#64748b",
"Blocked": "#ef4444",
},
"badge_fallback_color": "#94a3b8", # color for any unlisted value
}
| Option | Default | Description |
|---|---|---|
badge_colors | {} | Mapping of displayed text to fill color (string or light/dark tuple). |
badge_fallback_color | None | Fill color when the value is not in badge_colors. |
badge_fallback_handler | None | Callable (value, row, column) -> BadgeStyle, color, or None for dynamic colors. See BadgeStyle. |
Column Type: checkbox
Displays boolean row values as a clickable checkbox. The checked state is based on the truthiness of the cell value. Clicking toggles the value and fires on_checkbox_toggle with the updated row.
from CTkDataTable import CTkDataTable, TableRowEvent
columns = [{"key": "approved", "title": "Approved", "width": 120, "type": "checkbox"}]
rows = [{"approved": True}, {"approved": False}]
def handle_checkbox(event: TableRowEvent) -> None:
# event.row[event.column_key] holds the NEW toggled value
is_checked = event.row[event.column_key]
print(f"Checkbox toggled to: {is_checked}")
table = CTkDataTable(app, columns=columns, data=rows, on_checkbox_toggle=handle_checkbox)
Column Type: progress
Draws a numeric value as a horizontal progress bar with an optional text overlay. Sorts numerically.
{
"key": "completion",
"title": "Complete",
"width": 180,
"type": "progress",
"progress_min": 0,
"progress_max": 100,
"progress_color": "#2563eb",
"progress_background_color": "#dbeafe",
"progress_show_text": True,
"progress_text_format": "{percent:.0f}%",
}
| Option | Default | Description |
|---|---|---|
progress_min | 0.0 | Minimum value (must be less than progress_max). |
progress_max | 100.0 | Maximum value. |
progress_color | None | Progress fill color (string or light/dark tuple). |
progress_background_color | None | Progress track color. |
progress_show_text | True | Whether text is drawn over the bar. |
progress_text_format | "{percent:.0f}%" | Format string. Variables: value, percent, ratio, minimum, maximum. |
Column Type: link
Draws underlined text. A click calls on_link_click with a TableRowEvent where event.action_key == "link".
from CTkDataTable import CTkDataTable, TableRowEvent
columns = [
{"key": "name", "title": "Customer", "width": 220},
{"key": "profile", "title": "Profile", "width": 140, "type": "link", "link_color": "#2563eb"},
]
rows = [
{"name": "Northwind Components", "profile": "Open profile"},
{"name": "Meridian Foods", "profile": "Open profile"},
]
def handle_link(event: TableRowEvent) -> None:
print(f"Link clicked for: {event.row['name']}")
table = CTkDataTable(app, columns=columns, data=rows, on_link_click=handle_link)
| Option | Default | Description |
|---|---|---|
link_color | None | Link text and underline color (string or light/dark tuple). |
Column Type: pill_list
Displays tags as colored compact pills. Accepts lists, tuples, sets, frozensets, comma-separated strings, or a single value.
{
"key": "tags",
"title": "Tags",
"width": 240,
"type": "pill_list",
"pill_colors": {"Urgent": "#ef4444", "Finance": "#0ea5e9"},
"pill_fallback_color": "#64748b",
"pill_text_color": "#ffffff",
}
# Accepted row value formats:
# ["Urgent", "Finance"] — list
# "Sales, Follow-up" — comma-separated string
# "Urgent" — single string
| Option | Default | Description |
|---|---|---|
pill_colors | {} | Mapping of pill text to fill color. |
pill_fallback_color | None | Fill color for pills not found in pill_colors. |
pill_text_color | None | Text color for every pill in the column. |
Column Type: action
Draws row-level buttons. Clicking any button fires on_action_click. Action clicks do not also fire row or cell callbacks. Action columns are typically non-sortable.
from CTkDataTable import CTkDataTable, TableRowEvent
columns = [
{"key": "id", "title": "ID", "width": 80, "type": "number"},
{"key": "name", "title": "Name", "width": 220},
{
"key": "actions",
"title": "Actions",
"width": 180,
"type": "action",
"sortable": False,
"actions": [
{"key": "view", "label": "View"},
{"key": "delete", "label": "Delete", "fg_color": "#fee2e2", "text_color": "#991b1b"},
],
},
]
def handle_action(event: TableRowEvent) -> None:
if event.action_key == "view":
print(f"View: {event.row['name']}")
elif event.action_key == "delete":
table.delete_row_by_key("id", event.row["id"])
table = CTkDataTable(app, columns=columns, data=rows, on_action_click=handle_action)
| Option | Default | Description |
|---|---|---|
actions | () | Sequence of TableAction, mapping, or string action definitions. |
sortable | True (dict) / False (builder) | Almost always set to False for action columns. |
Feature: Search
Search is case-insensitive and checks all visible, non-action columns. Two integration styles:
Built-in search entry (placed above the table automatically)
table = CTkDataTable(
app,
columns=columns,
data=rows,
searchable=True,
search_delay_ms=150, # debounce typing; 0 = instant
on_search=lambda q: None, # optional callback when query changes
)
External search entry (placed in your own toolbar)
search = ctk.CTkEntry(app, placeholder_text="Search customers")
search.grid(row=0, column=0, sticky="ew")
table = CTkDataTable(app, columns=columns, data=rows)
table.grid(row=1, column=0, sticky="nsew")
search.bind("<KeyRelease>", lambda _e: table.search(search.get()))
Feature: Sorting
Header clicks sort sortable columns. Clicking the same header again toggles direction. Call sort_by() from code to set sort order programmatically.
def on_sort(column_key: str, ascending: bool) -> None:
direction = "asc" if ascending else "desc"
print(f"Sorted by '{column_key}' {direction}")
table = CTkDataTable(app, columns=columns, data=rows, on_sort=on_sort)
table.sort_by("amount", ascending=False) # programmatic sort
number, percentage, currency, progress → numeric sort.
date, datetime → chronological sort.
checkbox → boolean sort.
All others → case-insensitive text sort. Missing values always sort last.
Feature: Column Filters
Column filters stack with global search. A filtered column shows a header indicator.
# Built-in filter types
table.set_column_filter("status", {"type": "equals", "value": "Open"})
table.set_column_filter("name", {"type": "contains", "value": "north"})
table.set_column_filter("status", {"type": "not_equals", "value": "Closed"})
table.set_column_filter("status", {"type": "in", "values": ["Open", "Paused"]})
table.set_column_filter("active", {"type": "bool", "value": True})
table.set_column_filter("amount", {"type": "range", "min": 100, "max": 500})
table.set_column_filter("due", {"type": "date_range", "min": "2026-06-01", "max": "2026-06-30"})
# Callable filter — full row access, must return bool
table.set_column_filter(
"amount",
lambda value, row: value is not None and float(value) >= 1000 and row["status"] == "Open",
)
# Manage filters
active = table.get_column_filters()
table.clear_column_filter("status")
table.clear_column_filters()
| Filter type | Definition | Behavior |
|---|---|---|
contains | {"type":"contains","value":"north"} | Case-insensitive substring match. |
equals | {"type":"equals","value":"Open"} | Exact Python equality. |
not_equals | {"type":"not_equals","value":"Closed"} | Exact Python inequality. |
in | {"type":"in","values":[...]} | Value is in the provided list. |
bool | {"type":"bool","value":True} | bool(value) equals the expected boolean. |
range | {"type":"range","min":100,"max":500} | Numeric value within optional min/max bounds. |
date_range | {"type":"date_range","min":"...","max":"..."} | Parsed date within optional bounds. |
Feature: Selection
Single-row selection is enabled by default. Set multi_select=True for Ctrl-click toggle and Shift-click range selection.
table = CTkDataTable(app, columns=columns, data=rows, multi_select=True)
# Read selection
one_row = table.get_selected_row() # first selected row dict or None
many_rows = table.get_selected_rows() # all selected row dicts
src_indices = table.get_selected_indices() # source indices in view order
view_indices= table.get_selected_view_indices() # visible indices
# Bulk delete
count = table.delete_selected_rows() # returns number removed
Keyboard navigation (when the table canvas has focus)
| Key | Behavior |
|---|---|
| Up / Down | Move one row. |
| Page Up / Page Down | Move by one page. |
| Home / End | Move to first or last visible row. |
| Enter | Fires on_row_double_click for the focused row. |
| Shift + movement | Extends selection range (multi-select mode). |
Feature: Callbacks
All interaction callbacks receive a TableRowEvent. Action clicks do not also fire row or cell callbacks. Link clicks fire on_link_click instead of on_cell_click.
from CTkDataTable import TableRowEvent
def row_clicked(event: TableRowEvent) -> None:
print(event.source_index, event.row)
def cell_clicked(event: TableRowEvent) -> None:
print(event.column_key, event.row[event.column_key])
def action_clicked(event: TableRowEvent) -> None:
print(event.action_key, event.row["id"])
def link_clicked(event: TableRowEvent) -> None:
print("Link clicked for:", event.row["name"])
def checkbox_toggled(event: TableRowEvent) -> None:
print(event.column_key, "=", event.row[event.column_key])
table = CTkDataTable(
app,
columns=columns,
data=rows,
on_row_click=row_clicked,
on_row_double_click=row_clicked,
on_cell_click=cell_clicked,
on_action_click=action_clicked,
on_link_click=link_clicked,
on_checkbox_toggle=checkbox_toggled,
)
Feature: Context Menus
Pass context_menu to show a right-click row menu. On macOS, Control-click is also bound.
from CTkDataTable import TableRowEvent
def handle_context(event: TableRowEvent) -> None:
if event.action_key == "copy_id":
app.clipboard_clear()
app.clipboard_append(str(event.row["id"]))
table = CTkDataTable(
app,
columns=columns,
data=rows,
context_menu=[
{"key": "copy_id", "label": "Copy ID"},
{"key": "view", "label": "View"},
"archive", # string form: key="archive", label="Archive"
],
on_context_action=handle_context,
)
Feature: Resizable Columns & Horizontal Scroll
Set resizable_columns=True to let users drag header dividers. Set column_width_mode="fill" when visible columns should expand or shrink with the table width. Set horizontal_scroll=True when the total minimum width may be wider than the table.
table = CTkDataTable(
app,
columns=columns,
data=rows,
column_width_mode="fill", # expand or shrink columns to fill the viewport
resizable_columns=True, # users can drag column header dividers
min_column_width=64, # minimum logical width after dragging
horizontal_scroll=True, # shows horizontal scrollbar when needed
)
# Programmatic width control
table.set_column_width("name", 260)
current = table.get_column_width("name")
When horizontal_scroll=True, Shift + mouse wheel scrolls the table horizontally.
Feature: Table Styling
Use style for table-wide colors, spacing, and radii. Pass a dictionary or TableStyle to the constructor. Call configure_style() to merge changes, or set_style() to replace the entire style.
from CTkDataTable import CTkDataTable, TableStyle
# Pass style at creation
table = CTkDataTable(
app,
columns=columns,
data=rows,
style={
"surface_bg": "#ffffff",
"header_bg": "#111827",
"header_text_color": "#ffffff",
"row_alt_bg": "#f8fafc",
"hover_bg": "#e0f2fe",
"selected_bg": "#2563eb",
"selected_text_color": "#ffffff",
"divider_color": "#dbe3ef",
"border_color": "#cbd5e1",
"corner_radius": 12,
"cell_padding_x": 14,
"badge_radius": 7,
},
)
# Merge style changes later (keeps options not listed)
table.configure_style(
link_text_color="#0f766e",
checkbox_fill_checked="#16a34a",
)
# Replace entire style (accepts light/dark tuples for dark-mode support)
table.set_style(
TableStyle(
surface_bg=("#ffffff", "#111827"),
text_color=("#111827", "#e5e7eb"),
header_bg=("#f1f5f9", "#020617"),
)
)
Feature: Style Hooks
Style hooks let you color individual rows or cells dynamically. You must set enable_style_hooks=True in the constructor.
def row_style(row):
if row["status"] == "Overdue":
return {"fg_color": "#fff7ed", "text_color": "#9a3412"}
return None
def cell_style(_row, column_key, value):
if column_key == "amount" and value < 0:
return {"text_color": "#dc2626"}
return None
table = CTkDataTable(
app,
columns=columns,
data=rows,
enable_style_hooks=True, # required!
row_style=row_style,
cell_style=cell_style,
)
Callbacks return None or a dict with fg_color and/or text_color. Colors accept strings or CustomTkinter light/dark tuples.
Feature: Loading State & Async Loading
Manual loading state
def reload() -> None:
table.set_loading(True)
app.after(50, finish_reload) # keeps UI responsive
def finish_reload() -> None:
table.set_data(fetch_rows())
table.set_loading(False)
Background thread loading
import time
def fetch_rows():
time.sleep(1) # simulate a database or network call
return [
{"id": 1, "name": "Northwind Components", "status": "Open"},
{"id": 2, "name": "Meridian Foods", "status": "Closed"},
]
thread = table.load_async(
fetch_rows,
on_success=lambda rows: print(f"Loaded {len(rows)} rows"),
on_error=lambda err: print(f"Failed: {err}"),
clear_on_error=False, # keep old data visible on failure
)
load_async() automatically sets the loading state, runs fetch_rows on a daemon thread, then calls set_data() safely on the Tkinter thread. Returns the threading.Thread.
Feature: Error State
table.set_error("Could not refresh customer data") # shows error overlay
table.clear_error() # returns to normal data view
Tutorial 4: Complete Mini App
This app combines search, sorting, badge columns, currency, dates, checkboxes, action buttons, context menus, footer summaries, and multi-select in one working example you can copy and run directly.
from datetime import date, timedelta
import customtkinter as ctk
from CTkDataTable import CTkDataTable, TableRowEvent
app = ctk.CTk()
app.title("Work Orders")
app.geometry("1080x620")
app.grid_columnconfigure(0, weight=1)
app.grid_rowconfigure(2, weight=1)
toolbar = ctk.CTkFrame(app, corner_radius=0)
toolbar.grid(row=0, column=0, sticky="ew", padx=14, pady=(14, 8))
toolbar.grid_columnconfigure(0, weight=1)
search = ctk.CTkEntry(toolbar, placeholder_text="Search work orders")
search.grid(row=0, column=0, sticky="ew", padx=(10, 8), pady=10)
detail = ctk.CTkLabel(app, text="No row selected", anchor="w")
detail.grid(row=1, column=0, sticky="ew", padx=14, pady=(0, 8))
today = date.today()
columns = [
{"key": "id", "title": "WO", "width": 90},
{"key": "title", "title": "Title", "width": 260},
{
"key": "priority", "title": "Priority", "width": 120, "type": "badge",
"badge_colors": {"High": "#ef4444", "Medium": "#f59e0b", "Low": "#22c55e"},
"badge_fallback_color": "#64748b",
},
{"key": "cost", "title": "Cost", "width": 110, "type": "currency"},
{"key": "due", "title": "Due", "width": 125, "type": "date", "date_format": "%d %b"},
{"key": "complete", "title": "Done", "width": 90, "type": "checkbox"},
{
"key": "actions", "title": "Actions", "width": 170, "type": "action", "sortable": False,
"actions": [{"key": "view", "label": "View"}, {"key": "delete", "label": "Delete"}],
},
]
rows = [
{"id": "WO-001", "title": "Replace intake filter", "priority": "High", "cost": 450, "due": today + timedelta(days=2), "complete": False, "actions": None},
{"id": "WO-002", "title": "Update inspection checklist","priority": "Low", "cost": 120, "due": today + timedelta(days=8), "complete": True, "actions": None},
{"id": "WO-003", "title": "Audit calibration records", "priority": "Medium", "cost": 275, "due": today + timedelta(days=4), "complete": False, "actions": None},
]
table: CTkDataTable | None = None
def select_row(event: TableRowEvent) -> None:
detail.configure(text=f"Selected {event.row['id']}: {event.row['title']}")
def handle_action(event: TableRowEvent) -> None:
if table is None:
return
if event.action_key == "view":
detail.configure(text=f"Viewing {event.row['id']}: {event.row['title']}")
elif event.action_key == "delete":
table.delete_row_by_key("id", event.row["id"])
def handle_context(event: TableRowEvent) -> None:
if event.action_key == "copy_id":
app.clipboard_clear()
app.clipboard_append(event.row["id"])
detail.configure(text=f"Copied {event.row['id']} to clipboard")
def handle_checkbox(event: TableRowEvent) -> None:
detail.configure(text=f"{event.row['id']} complete: {event.row[event.column_key]}")
table = CTkDataTable(
app,
columns=columns,
data=rows,
horizontal_scroll=True,
multi_select=True,
footer=True,
summaries={"id": "count", "cost": "sum"},
context_menu=[{"key": "copy_id", "label": "Copy ID"}],
on_row_click=select_row,
on_action_click=handle_action,
on_context_action=handle_context,
on_checkbox_toggle=handle_checkbox,
)
table.grid(row=2, column=0, sticky="nsew", padx=14, pady=(0, 14))
search.bind("<KeyRelease>", lambda _event: table.search(search.get()))
app.mainloop()
API: Constructor
CTkDataTable inherits from customtkinter.CTkFrame. Visual keyword arguments (corner_radius, border_width, fg_color, border_color) style the table viewport frame. Defaults: corner_radius=12, border_width=1.
CTkDataTable(
master,
columns,
data=None,
*,
row_height=42,
header_height=44,
footer_height=38,
font=None,
header_font=None,
horizontal_scroll=False,
column_width_mode="fixed",
multi_select=False,
searchable=False,
search_delay_ms=0,
resizable_columns=False,
min_column_width=48,
style=None,
enable_style_hooks=False,
row_style=None,
cell_style=None,
context_menu=None,
on_context_action=None,
footer=False,
summaries=None,
empty_message="No records to display",
loading_message="Loading records...",
error_message="Could not load records",
on_row_click=None,
on_row_double_click=None,
on_cell_click=None,
on_action_click=None,
on_link_click=None,
on_checkbox_toggle=None,
on_sort=None,
on_search=None,
**frame_kwargs,
)
| Argument | Default | Description |
|---|---|---|
master | Required | Parent widget (CTk, CTkFrame, tab, etc.). |
columns | Required | Sequence of dictionaries, TableColumn, or Column objects. |
data | None | Iterable of row-like mappings. None creates an empty table. |
row_height | 42 | Row height in logical pixels (min 28). |
header_height | 44 | Header height in logical pixels (min 32). |
footer_height | 38 | Footer height in logical pixels (min 28). Used when footer=True. |
font | None | Cell font. Tkinter font object or tuple, e.g. ("Segoe UI", 13). |
header_font | None | Header font. Defaults to bold font. |
horizontal_scroll | False | Enables horizontal scrollbar. |
column_width_mode | "fixed" | "fixed" uses configured widths; "fill" distributes visible columns across the table width. |
multi_select | False | Enables Ctrl-click toggle and Shift-click range selection. |
searchable | False | Adds built-in search entry above the table. |
search_delay_ms | 0 | Debounce delay for built-in search (milliseconds). |
resizable_columns | False | Lets users drag header dividers to resize columns. |
min_column_width | 48 | Minimum logical column width when resizing (min 24). |
style | None | TableStyle or mapping controlling colors, spacing, and radii. |
enable_style_hooks | False | Must be True to use row_style / cell_style. |
row_style | None | Callable (row) -> mapping or None. Returns fg_color / text_color. |
cell_style | None | Callable (row, column_key, value) -> mapping or None. |
context_menu | None | Right-click menu actions. Sequence of TableAction, mapping, or strings. |
on_context_action | None | Callable (event: TableRowEvent) -> None. |
footer | False | Shows a footer summary row. |
summaries | None | Mapping of column key to summary name or callable. |
empty_message | "No records to display" | Message shown when there are no rows. |
loading_message | "Loading records..." | Message shown in loading state. |
error_message | "Could not load records" | Default message for set_error(None). |
on_row_click | None | Callable (event: TableRowEvent) -> None. |
on_row_double_click | None | Also called by the Enter key on focused row. |
on_cell_click | None | Callable (event: TableRowEvent) -> None. |
on_action_click | None | Callable (event: TableRowEvent) -> None. |
on_link_click | None | Callable (event: TableRowEvent) -> None. |
on_checkbox_toggle | None | Fires after a checkbox cell is toggled. |
on_sort | None | Callable (column_key: str, ascending: bool) -> None. |
on_search | None | Callable (query: str) -> None. |
API: Methods
| Method | Returns | Description |
|---|---|---|
| Data | ||
set_data(data) | None | Replace all rows and clear selection. |
get_data() | list[dict] | Shallow copies of all source rows. |
clear() | None | Remove all rows. |
add_row(row) | int | Append one row; returns source index. |
add_rows(rows) | list[int] | Append multiple rows; returns source indices. |
update_row(index, row) | None | Replace source row by source index. |
update_view_row(view_index, row) | None | Replace row by current visible index. |
update_row_where(key, value, new_row) | bool | Replace first row where key == value. |
delete_row(index) | None | Delete by source index. |
delete_view_row(view_index) | None | Delete by visible index. |
delete_row_where(key, value) | bool | Delete first row where key == value. |
delete_row_by_key(key, value) | bool | Alias for delete_row_where(). |
delete_selected_rows() | int | Delete all selected rows; returns count. |
| Row Access | ||
get_row(index) | dict | Get source row by source index. |
get_view_row(view_index) | dict | Get row by visible index. |
find_row_index(key, value) | int or None | Find first source row where key == value. |
source_index_for_view_index(vi) | int | Convert visible index to source index. |
view_index_for_source_index(si) | int or None | Convert source index to visible index; None if hidden. |
| Selection | ||
get_selected_row() | dict or None | First selected row. |
get_selected_rows() | list[dict] | All selected rows. |
get_selected_indices() | list[int] | Selected source indices in view order. |
get_selected_view_indices() | list[int] | Selected visible row indices. |
| Search, Sort & Filter | ||
sort_by(key, ascending=True) | None | Sort visible rows. |
search(query) | None | Case-insensitive global search. |
filter(query) | None | Backward-compatible alias for search(). |
set_column_filter(key, definition) | None | Add or replace one column filter. |
clear_column_filter(key) | None | Clear one column filter. |
clear_column_filters() | None | Clear all column filters. |
get_column_filters() | dict | Active column filters. |
| Columns & Style | ||
get_columns() | tuple[TableColumn, ...] | Normalized column definitions. |
set_columns(columns) | None | Replace columns, preserving compatible state. |
set_column_width(key, width) | None | Set one column width in logical pixels. |
get_column_width(key) | int | Read one logical column width. |
set_column_width_mode(mode) | None | Switch between "fixed" and "fill" column layout. |
get_column_width_mode() | "fixed" or "fill" | Read the current column layout mode. |
refresh() | None | Redraw without changing rows or state. |
get_style() | TableStyle | Current table-wide style. |
set_style(style=None, **kw) | None | Replace entire style and redraw. |
configure_style(style=None, **kw) | None | Merge style changes and redraw. |
| State | ||
set_loading(state) | None | Show (True) or hide (False) loading state. |
set_error(message=None) | None | Show error overlay. None uses error_message. |
clear_error() | None | Hide error without changing rows. |
load_async(fetch, on_success, on_error, clear_on_error) | threading.Thread | Load rows on a background thread. |
API: TableStyle
Use TableStyle or a plain dictionary to customize table-wide colors, spacing, and radii. Any option left as None falls back to the active CustomTkinter theme.
| Group | Style options |
|---|---|
| Shape & spacing | corner_radius, border_width, cell_padding_x, badge_padding_x, button_padding_x, badge_radius, checkbox_radius, progress_radius, pill_radius, action_radius |
| Backgrounds | canvas_bg, surface_bg, row_bg, row_alt_bg, header_bg, footer_bg, hover_bg, selected_bg, selected_hover_bg |
| Text colors | text_color, hover_text_color, selected_text_color, selected_hover_text_color, muted_text_color, header_text_color, footer_text_color |
| Lines & indicators | divider_color, header_divider_color, border_color, sort_indicator_color, filter_indicator_color, loading_indicator_color |
| Feature cells | badge_bg, badge_text_color, pill_bg, pill_text_color, progress_bg, progress_fill, progress_text_color, link_text_color, checkbox_fill, checkbox_fill_checked, checkbox_border, checkbox_check, action_bg, action_border, action_text_color |
Supported aliases
| Alias | Canonical option |
|---|---|
fg_color | canvas_bg |
divider | divider_color |
header_divider | header_divider_color |
table_border | border_color |
text, hover_text, selected_text, etc. | Matching *_color option |
API: TableColumn
All column definition forms normalize to TableColumn internally. You can pass TableColumn instances directly to the constructor or to set_columns().
| Option | Default | Description |
|---|---|---|
key | Required | Row dictionary field name. Must match exactly. |
title | Title-cased key (dict/builder); required for direct TableColumn | Header label. |
width | 140 (dict/builder); required for direct TableColumn | Preferred width in logical pixels. |
align | Type-dependent | "left", "center", or "right". |
visible | True | Hidden columns are not rendered or searched. |
sortable | True | Whether header clicks sort this column. |
type | "text" | One of the 12 column types. |
formatter | None | Callable (value, row) -> str. Runs before built-in formatting. |
number_format | None | Format string or callable for number columns. |
percentage_format | "{value:.0f}%" | Format string for percentage columns. |
percentage_multiplier | 1.0 | Multiplied by raw value before formatting. |
currency_symbol | "$" | Currency prefix/symbol. |
currency_format | "{symbol}{value:,.2f}" | Format for positive values. |
currency_negative_format | "-{symbol}{value:,.2f}" | Format for negative values. |
date_format | "%Y-%m-%d" | strftime display format. |
datetime_format | "%Y-%m-%d %H:%M" | strftime display format. |
badge_colors | {} | Text → fill color mapping for badge columns. |
badge_fallback_color | None | Fill for unlisted badge values. |
badge_fallback_handler | None | Callable for dynamic badge colors. |
pill_colors | {} | Text → fill color mapping for pill columns. |
pill_fallback_color | None | Fill for unlisted pill values. |
pill_text_color | None | Text color for all pills in the column. |
actions | () | Action button definitions for action columns. |
progress_min | 0.0 | Minimum value for progress bar. |
progress_max | 100.0 | Maximum value for progress bar. |
progress_color | None | Progress fill color. |
progress_background_color | None | Progress track color. |
progress_show_text | True | Whether text is drawn over the bar. |
progress_text_format | "{percent:.0f}%" | Format string for progress text. |
link_color | None | Link text and underline color. |
metadata | {} | App-specific data. Not rendered. |
API: Column Builder
Column("key") returns a mapping accepted anywhere a column dictionary is accepted. Methods are chainable.
| Method | Parameters | Effect |
|---|---|---|
Column(key) | key: str | Starts a text column with the given row key. |
.title(t) | str | Sets header text. |
.width(logical_px) | int | Sets preferred column width in logical pixels. |
.align(a) | "left" / "center" / "right" | Sets alignment. |
.hide() | — | Sets visible=False. |
.no_sort() | — | Sets sortable=False. |
.fmt(func) | Callable (value, row) -> str | Sets a custom formatter. |
.metadata(**kw) | Keyword values | Sets metadata. |
.text() | — | Sets type="text". |
.number(format=None) | Format string or callable | Sets number type options. |
.percentage(format, multiplier) | Keyword-only | Sets percentage type options. |
.currency(symbol, format, negative_format) | Keyword-only | Sets currency type options. |
.date(fmt) | strftime format string | Sets date type options. |
.datetime(fmt) | strftime format string | Sets datetime type options. |
.badge(colors, fallback_color, fallback_handler) | Keyword-only | Sets badge type options. |
.pill_list(colors, fallback_color, text_color) | Keyword-only | Sets pill_list type options. |
.checkbox() | — | Sets type="checkbox". |
.progress(minimum, maximum, color, background_color, show_text, text_format) | Keyword-only | Sets progress type options. |
.link(color=None) | Optional color | Sets link type options. |
.action(buttons, sortable=False) | Sequence of actions | Sets action type options. |
API: TableAction
TableAction defines a button in an action column or an item in context_menu. Dictionary and string forms are accepted anywhere TableAction is.
from CTkDataTable import TableAction
# Object form
TableAction("view", "View")
TableAction("delete", "Delete", fg_color="#fee2e2", text_color="#991b1b")
# Dictionary form
{"key": "view", "label": "View", "width": 72}
# String form — key is the string, label is title-cased
"archive" # key="archive", label="Archive"
| Option | Default | Description |
|---|---|---|
key | Required | String identifier. Provided in event.action_key. |
label | Title-cased key | Button or menu item text. |
width | None | Fixed button width in pixels. Auto-measured if omitted. |
fg_color | None | Button background color. |
text_color | None | Button label color. |
border_color | None | Button border color. |
API: BadgeStyle
Return BadgeStyle from a badge_fallback_handler to fully control the badge's text and colors for values not in badge_colors.
from typing import Any, Mapping
from CTkDataTable import BadgeStyle, TableColumn
def status_fallback(value: Any, _row: Mapping[str, Any], _column: TableColumn) -> BadgeStyle:
text = str(value or "Unknown")
return BadgeStyle(text=text, fill_color="#64748b", text_color="#ffffff")
columns = [{
"key": "status", "title": "Status", "width": 140, "type": "badge",
"badge_colors": {"Open": "#22c55e", "Closed": "#64748b"},
"badge_fallback_handler": status_fallback,
}]
| Field | Default | Description |
|---|---|---|
text | Required | Text drawn inside the badge. |
fill_color | Required | Badge background color (string or light/dark tuple). |
text_color | None | Badge text color. |
Fallback handler return values
| Return value | Result |
|---|---|
BadgeStyle(...) | Full control over text, fill, and text color. |
| Color string or light/dark tuple | Keeps the original cell text; uses the returned color as fill. |
None | Falls back to badge_fallback_color, then the table default. |
API: TableRowEvent
All interaction callbacks receive a TableRowEvent. The attributes available depend on the callback type.
| Attribute | Type | Description |
|---|---|---|
row | dict[str, Any] | Shallow copy of the row at the time of the event. |
source_index | int | Index in the original source data list (unaffected by sort/filter). |
view_index | int | Index in the current filtered and sorted view. |
column_key | str or None | Clicked column key. Set for cell, link, action, and checkbox events. |
action_key | str or None | Clicked action identifier. Link events: "link". Checkbox events: "checkbox". |
Use source_index for operations on the underlying data (e.g., update_row(event.source_index, ...)). Use view_index when you want the row's position in the current visible ordering.
API: rows_from_cursor
Converts a DB-API cursor result into a list of dictionaries using cursor.description for column names. Call this after executing a SELECT query on a plain cursor (one that returns tuples, not dicts).
from CTkDataTable import rows_from_cursor
cursor.execute("SELECT id, name, status FROM customers ORDER BY name")
rows = rows_from_cursor(cursor) # list of dicts
table.set_data(rows)
| Parameter | Description | Returns |
|---|---|---|
cursor | DB-API cursor after a SELECT query; cursor.description must be set. | list[dict] |
FAQ & Troubleshooting
The most common cause is a key mismatch. The column["key"] must match the row dictionary key exactly — identical spelling and capitalization.
# Wrong — keys don't match
column = {"key": "CustomerName", ...}
row = {"customer_name": "Alice"} # different key!
# Correct
column = {"key": "customer_name", ...}
row = {"customer_name": "Alice"}
Set connection.row_factory = sqlite3.Row before executing any queries. Without it, SQLite returns plain tuples which the table can't use directly.
import sqlite3
connection = sqlite3.connect("mydb.db")
connection.row_factory = sqlite3.Row # must set this before executing
rows = connection.execute("SELECT id, name FROM customers").fetchall()
table.set_data(rows)
Alternatively use rows_from_cursor(cursor) with a plain cursor after cursor.execute().
Configure the parent widget's grid column and row weights to allow expansion, and use sticky="nsew":
app.grid_columnconfigure(0, weight=1) # allow column 0 to grow
app.grid_rowconfigure(0, weight=1) # allow row 0 to grow
table.grid(row=0, column=0, sticky="nsew", padx=16, pady=16)
This means your app is loading CTkDataTable 0.1.0 or another older copy. Upgrade inside the same Python environment that runs your app:
python -m pip install --upgrade CTkDataTable
You can confirm the loaded version and file path with:
python -m pip show CTkDataTable
Style hooks require enable_style_hooks=True in the constructor. Without this flag, row_style and cell_style callbacks are never called regardless of whether they're provided.
table = CTkDataTable(
app,
columns=columns,
data=rows,
enable_style_hooks=True, # required!
row_style=my_row_style,
cell_style=my_cell_style,
)
Search only checks visible, non-action columns. A column is excluded if:
- It has
"visible": False(or.hide()in the Column builder). - Its
typeis"action".
Make sure the column you expect to be searched is visible and is not an action column.
The table updates the internal row value immediately when a checkbox is clicked. Use on_checkbox_toggle to react and persist the change:
def handle_checkbox(event: TableRowEvent) -> None:
new_value = event.row[event.column_key] # True or False
# save to your database, update local model, etc.
table = CTkDataTable(app, columns=columns, data=rows, on_checkbox_toggle=handle_checkbox)
Call table.set_data(rows) to replace all rows at once. For slow queries (network, database), use table.load_async() to keep the UI responsive during loading:
def fetch():
return db.query("SELECT ...")
table.load_async(fetch, on_success=lambda rows: print(f"{len(rows)} rows loaded"))
CTkDataTable uses virtualized rendering — only the rows currently visible on screen are rendered as canvas items. A table with 100,000 rows uses roughly the same rendering resources as one with 50 rows.
The full row list is kept in memory, so very large datasets use proportional RAM. For slow data fetching (network / database), use load_async() to load rows in a background thread without blocking the UI.