✦ CustomTkinter Widget

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.

$ pip install --upgrade CTkDataTable
Virtualized Rendering 12 Column Types CustomTkinter Python 3.11+

Installation


Install CTkDataTable and CustomTkinter in your Python environment:

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

python
import customtkinter as ctk
from CTkDataTable import CTkDataTable

Optional public helpers — import only what you need:

python
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_mode with "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() and get_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.
Upgrade existing projects

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:

  1. Define columns — each column needs a key that exactly matches your row dictionaries.
  2. Provide rows — a list of dictionaries (or any mapping-like objects).
  3. Place the table with grid(), pack(), or place().
⚠ The Key Rule

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.

python — minimal example
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) and app.grid_rowconfigure(0, weight=1) so the table expands to fill the window.
  • Use sticky="nsew" in table.grid().
  • Pass data=rows to the constructor, or call table.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.

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

python
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 sourceHow to use it
dictPass directly in data, set_data(), add_row(), or add_rows().
Any mapping objectPass directly — anything that behaves like a dictionary works.
sqlite3.RowSet connection.row_factory = sqlite3.Row, then pass fetched rows directly.
SQLAlchemy result rowsPass fetched result rows directly (uses _mapping).
PostgreSQL dictionary rowsUse dict_row factory (psycopg 3) or RealDictCursor (psycopg 2).
Plain DB-API tuple rowsConvert 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.

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

python
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

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

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

python — dictionary (shortest)
columns = [
    {"key": "id",   "title": "ID",       "width": 80,  "type": "number"},
    {"key": "name", "title": "Customer", "width": 220},
]
python — TableColumn (typed)
from CTkDataTable import TableColumn

columns = [
    TableColumn(key="id",   title="ID",       width=80,  type="number"),
    TableColumn(key="name", title="Customer", width=220),
]
python — Column builder (fluent)
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

OptionDefaultDescription
keyRequiredRow dictionary field name. Must match exactly.
titleTitle-cased keyColumn header label.
width140Preferred column width in logical pixels.
alignType-dependent"left", "center", or "right".
visibleTrueHidden columns are not rendered or searched.
sortableTrueWhether header clicks sort this column.
formatterNoneCallable (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, currencyright
checkbox, action, progresscenter
All other typesleft

Column Type: text


The default type. Displays row values as plain text and sorts case-insensitively.

python
{"key": "name", "title": "Customer", "width": 220, "type": "text"}
OptionDefaultDescription
type"text"Plain text display. Also the default when type is omitted.
formatterNoneCallable (value, row) -> str for custom display text.

Column Type: number


Right-aligns values by default, sorts numerically, and accepts an optional format string.

python
{
    "key": "quantity",
    "title": "Quantity",
    "width": 140,
    "type": "number",
    "number_format": "{:,.0f}",   # "12,400"
}
OptionDefaultDescription
number_formatNoneFormat 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.

python
{
    "key": "margin",
    "title": "Margin",
    "width": 150,
    "type": "percentage",
    "percentage_format": "{value:.1f}%",
    # "percentage_multiplier": 100,  # use this for 0–1 ratio values
}
OptionDefaultDescription
percentage_format"{value:.0f}%"Format string using value, raw_value, and multiplier.
percentage_multiplier1.0Multiplied 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.

python
{
    "key": "amount",
    "title": "Amount",
    "width": 150,
    "type": "currency",
    "currency_symbol": "GBP ",
    "currency_format": "{symbol}{value:,.2f}",
    "currency_negative_format": "({symbol}{value:,.2f})",
}
OptionDefaultDescription
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.

python
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
OptionDefaultDescription
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.

python
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"}]
OptionDefaultDescription
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.

python
{
    "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
}
OptionDefaultDescription
badge_colors{}Mapping of displayed text to fill color (string or light/dark tuple).
badge_fallback_colorNoneFill color when the value is not in badge_colors.
badge_fallback_handlerNoneCallable (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.

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

python
{
    "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}%",
}
OptionDefaultDescription
progress_min0.0Minimum value (must be less than progress_max).
progress_max100.0Maximum value.
progress_colorNoneProgress fill color (string or light/dark tuple).
progress_background_colorNoneProgress track color.
progress_show_textTrueWhether text is drawn over the bar.
progress_text_format"{percent:.0f}%"Format string. Variables: value, percent, ratio, minimum, maximum.

Column Type: pill_list


Displays tags as colored compact pills. Accepts lists, tuples, sets, frozensets, comma-separated strings, or a single value.

python
{
    "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
OptionDefaultDescription
pill_colors{}Mapping of pill text to fill color.
pill_fallback_colorNoneFill color for pills not found in pill_colors.
pill_text_colorNoneText 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.

python
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)
OptionDefaultDescription
actions()Sequence of TableAction, mapping, or string action definitions.
sortableTrue (dict) / False (builder)Almost always set to False for action columns.

Feature: Sorting


Header clicks sort sortable columns. Clicking the same header again toggles direction. Call sort_by() from code to set sort order programmatically.

python
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
Sort behavior by type

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.

python
# 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 typeDefinitionBehavior
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.

python
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)

KeyBehavior
Up / DownMove one row.
Page Up / Page DownMove by one page.
Home / EndMove to first or last visible row.
EnterFires on_row_double_click for the focused row.
Shift + movementExtends 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.

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

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

python
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")
Tip

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.

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

python
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

python
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

python
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


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

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

python — signature
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,
)
ArgumentDefaultDescription
masterRequiredParent widget (CTk, CTkFrame, tab, etc.).
columnsRequiredSequence of dictionaries, TableColumn, or Column objects.
dataNoneIterable of row-like mappings. None creates an empty table.
row_height42Row height in logical pixels (min 28).
header_height44Header height in logical pixels (min 32).
footer_height38Footer height in logical pixels (min 28). Used when footer=True.
fontNoneCell font. Tkinter font object or tuple, e.g. ("Segoe UI", 13).
header_fontNoneHeader font. Defaults to bold font.
horizontal_scrollFalseEnables horizontal scrollbar.
column_width_mode"fixed""fixed" uses configured widths; "fill" distributes visible columns across the table width.
multi_selectFalseEnables Ctrl-click toggle and Shift-click range selection.
searchableFalseAdds built-in search entry above the table.
search_delay_ms0Debounce delay for built-in search (milliseconds).
resizable_columnsFalseLets users drag header dividers to resize columns.
min_column_width48Minimum logical column width when resizing (min 24).
styleNoneTableStyle or mapping controlling colors, spacing, and radii.
enable_style_hooksFalseMust be True to use row_style / cell_style.
row_styleNoneCallable (row) -> mapping or None. Returns fg_color / text_color.
cell_styleNoneCallable (row, column_key, value) -> mapping or None.
context_menuNoneRight-click menu actions. Sequence of TableAction, mapping, or strings.
on_context_actionNoneCallable (event: TableRowEvent) -> None.
footerFalseShows a footer summary row.
summariesNoneMapping 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_clickNoneCallable (event: TableRowEvent) -> None.
on_row_double_clickNoneAlso called by the Enter key on focused row.
on_cell_clickNoneCallable (event: TableRowEvent) -> None.
on_action_clickNoneCallable (event: TableRowEvent) -> None.
on_link_clickNoneCallable (event: TableRowEvent) -> None.
on_checkbox_toggleNoneFires after a checkbox cell is toggled.
on_sortNoneCallable (column_key: str, ascending: bool) -> None.
on_searchNoneCallable (query: str) -> None.

API: Methods


MethodReturnsDescription
Data
set_data(data)NoneReplace all rows and clear selection.
get_data()list[dict]Shallow copies of all source rows.
clear()NoneRemove all rows.
add_row(row)intAppend one row; returns source index.
add_rows(rows)list[int]Append multiple rows; returns source indices.
update_row(index, row)NoneReplace source row by source index.
update_view_row(view_index, row)NoneReplace row by current visible index.
update_row_where(key, value, new_row)boolReplace first row where key == value.
delete_row(index)NoneDelete by source index.
delete_view_row(view_index)NoneDelete by visible index.
delete_row_where(key, value)boolDelete first row where key == value.
delete_row_by_key(key, value)boolAlias for delete_row_where().
delete_selected_rows()intDelete all selected rows; returns count.
Row Access
get_row(index)dictGet source row by source index.
get_view_row(view_index)dictGet row by visible index.
find_row_index(key, value)int or NoneFind first source row where key == value.
source_index_for_view_index(vi)intConvert visible index to source index.
view_index_for_source_index(si)int or NoneConvert source index to visible index; None if hidden.
Selection
get_selected_row()dict or NoneFirst 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)NoneSort visible rows.
search(query)NoneCase-insensitive global search.
filter(query)NoneBackward-compatible alias for search().
set_column_filter(key, definition)NoneAdd or replace one column filter.
clear_column_filter(key)NoneClear one column filter.
clear_column_filters()NoneClear all column filters.
get_column_filters()dictActive column filters.
Columns & Style
get_columns()tuple[TableColumn, ...]Normalized column definitions.
set_columns(columns)NoneReplace columns, preserving compatible state.
set_column_width(key, width)NoneSet one column width in logical pixels.
get_column_width(key)intRead one logical column width.
set_column_width_mode(mode)NoneSwitch between "fixed" and "fill" column layout.
get_column_width_mode()"fixed" or "fill"Read the current column layout mode.
refresh()NoneRedraw without changing rows or state.
get_style()TableStyleCurrent table-wide style.
set_style(style=None, **kw)NoneReplace entire style and redraw.
configure_style(style=None, **kw)NoneMerge style changes and redraw.
State
set_loading(state)NoneShow (True) or hide (False) loading state.
set_error(message=None)NoneShow error overlay. None uses error_message.
clear_error()NoneHide error without changing rows.
load_async(fetch, on_success, on_error, clear_on_error)threading.ThreadLoad 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.

GroupStyle options
Shape & spacingcorner_radius, border_width, cell_padding_x, badge_padding_x, button_padding_x, badge_radius, checkbox_radius, progress_radius, pill_radius, action_radius
Backgroundscanvas_bg, surface_bg, row_bg, row_alt_bg, header_bg, footer_bg, hover_bg, selected_bg, selected_hover_bg
Text colorstext_color, hover_text_color, selected_text_color, selected_hover_text_color, muted_text_color, header_text_color, footer_text_color
Lines & indicatorsdivider_color, header_divider_color, border_color, sort_indicator_color, filter_indicator_color, loading_indicator_color
Feature cellsbadge_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

AliasCanonical option
fg_colorcanvas_bg
dividerdivider_color
header_dividerheader_divider_color
table_borderborder_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().

OptionDefaultDescription
keyRequiredRow dictionary field name. Must match exactly.
titleTitle-cased key (dict/builder); required for direct TableColumnHeader label.
width140 (dict/builder); required for direct TableColumnPreferred width in logical pixels.
alignType-dependent"left", "center", or "right".
visibleTrueHidden columns are not rendered or searched.
sortableTrueWhether header clicks sort this column.
type"text"One of the 12 column types.
formatterNoneCallable (value, row) -> str. Runs before built-in formatting.
number_formatNoneFormat string or callable for number columns.
percentage_format"{value:.0f}%"Format string for percentage columns.
percentage_multiplier1.0Multiplied 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_colorNoneFill for unlisted badge values.
badge_fallback_handlerNoneCallable for dynamic badge colors.
pill_colors{}Text → fill color mapping for pill columns.
pill_fallback_colorNoneFill for unlisted pill values.
pill_text_colorNoneText color for all pills in the column.
actions()Action button definitions for action columns.
progress_min0.0Minimum value for progress bar.
progress_max100.0Maximum value for progress bar.
progress_colorNoneProgress fill color.
progress_background_colorNoneProgress track color.
progress_show_textTrueWhether text is drawn over the bar.
progress_text_format"{percent:.0f}%"Format string for progress text.
link_colorNoneLink 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.

MethodParametersEffect
Column(key)key: strStarts a text column with the given row key.
.title(t)strSets header text.
.width(logical_px)intSets 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) -> strSets a custom formatter.
.metadata(**kw)Keyword valuesSets metadata.
.text()Sets type="text".
.number(format=None)Format string or callableSets number type options.
.percentage(format, multiplier)Keyword-onlySets percentage type options.
.currency(symbol, format, negative_format)Keyword-onlySets currency type options.
.date(fmt)strftime format stringSets date type options.
.datetime(fmt)strftime format stringSets datetime type options.
.badge(colors, fallback_color, fallback_handler)Keyword-onlySets badge type options.
.pill_list(colors, fallback_color, text_color)Keyword-onlySets pill_list type options.
.checkbox()Sets type="checkbox".
.progress(minimum, maximum, color, background_color, show_text, text_format)Keyword-onlySets progress type options.
.link(color=None)Optional colorSets link type options.
.action(buttons, sortable=False)Sequence of actionsSets 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.

python
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"
OptionDefaultDescription
keyRequiredString identifier. Provided in event.action_key.
labelTitle-cased keyButton or menu item text.
widthNoneFixed button width in pixels. Auto-measured if omitted.
fg_colorNoneButton background color.
text_colorNoneButton label color.
border_colorNoneButton 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.

python
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,
}]
FieldDefaultDescription
textRequiredText drawn inside the badge.
fill_colorRequiredBadge background color (string or light/dark tuple).
text_colorNoneBadge text color.

Fallback handler return values

Return valueResult
BadgeStyle(...)Full control over text, fill, and text color.
Color string or light/dark tupleKeeps the original cell text; uses the returned color as fill.
NoneFalls 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.

AttributeTypeDescription
rowdict[str, Any]Shallow copy of the row at the time of the event.
source_indexintIndex in the original source data list (unaffected by sort/filter).
view_indexintIndex in the current filtered and sorted view.
column_keystr or NoneClicked column key. Set for cell, link, action, and checkbox events.
action_keystr or NoneClicked action identifier. Link events: "link". Checkbox events: "checkbox".
source_index vs view_index

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

python
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)
ParameterDescriptionReturns
cursorDB-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.

python
# 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.

python
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":

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

bash
python -m pip install --upgrade CTkDataTable

You can confirm the loaded version and file path with:

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

python
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 type is "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:

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

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

↑ Top