CTkKanban
A focused Kanban board for CustomTkinter. Build desktop workflows with schema-driven card data, explicit editing, granular permissions, dependable movement, application-owned persistence, and a typed API.
Installation
Install CTkKanban from PyPI. CustomTkinter is installed automatically as a required dependency.
python -m pip install --upgrade CTkKanBan
CTkKanban requires Python 3.10 or newer. Its package metadata accepts CustomTkinter 5.2.2 or newer below major version 7.
Although the distribution name uses capitals, import the package with its canonical lowercase module name:
import customtkinter as ctk
from ctk_kanban import CTkKanbanBoard
Public helpers can be imported from the same package root:
from ctk_kanban import (
ActionConfig,
BoardConfig,
BoardModel,
BoardModelError,
BoardSnapshot,
Card,
CardField,
CardRecord,
Column,
ColumnRecord,
DEFAULT_FIELDS,
DEFAULT_THEME,
Field,
FieldDefinition,
FieldInput,
FieldType,
LayoutConfig,
TextConfig,
merge_config,
merge_theme,
normalize_row,
normalize_rows,
rows_from_cursor,
snapshot_from_cursors,
snapshot_from_rows,
__version__,
)
Release 2.2.1 Highlights
Version 2.2.1 includes the 2.2 feature set and restores wheel routing on CustomTkinter 6.0 without relying on version-specific private methods.
| Area | What changed |
|---|---|
| Concise fields | Field, CardField, string shorthand, and custom title keys make database columns easy to expose without verbose schema dictionaries. |
| Database setup | CTkKanbanBoard.from_rows() accepts row iterables or executed cursors plus explicit source-key mappings. |
| Custom forms | A simple on_card_open(card) callback and use_builtin_editor=False let the host application own card editing. |
| Responsive layout | fill_columns shares surplus width equally; editor close restores centred columns and columns retain their vertical fill. |
| Scrolling | Thicker scrollbars, coalesced geometry updates, resize-aware horizontal ranges, and axis-aware wheel input improve navigation. |
| Rendering | Normalized schemas and fonts are reused, unused per-card widgets are removed, and search reuses existing card widgets. |
| Compatibility | Wheel containment is now owned by CTkKanban and tested across CustomTkinter 5.2.2 and 6.0.0. |
| Examples | Focused runnable programs now cover normal setup, every input type, SQLite, custom editors, and asynchronous loading. |
The default title, description, priority, and tags behavior remains available when fields and config are omitted. Applications still on 1.x should read Migrating from 1.x because the focused 2.x architecture remains intentionally different.
Quick Start
Create a window, define columns and cards, then pack the board like any other CustomTkinter widget.
import customtkinter as ctk
from ctk_kanban import CTkKanbanBoard
ctk.set_appearance_mode("System")
app = ctk.CTk()
app.title("Project board")
app.geometry("1100x680")
board = CTkKanbanBoard(
app,
columns=[
{"id": "todo", "title": "To do"},
{"id": "doing", "title": "Doing"},
{"id": "done", "title": "Done"},
],
cards=[
{
"id": 1,
"column": "todo",
"title": "Publish CTkKanban",
"description": "Finish the documentation and release checks.",
"priority": "High",
"tags": ["release", "docs"],
}
],
on_change=lambda event: print(event["type"], event["data"]),
)
board.pack(fill="both", expand=True)
app.mainloop()
The board owns its current in-memory model. Your application owns durable storage. Read snapshots with get_data(), replace them with set_data(), and persist successful changes from on_change.
Runnable Examples
Choose the smallest program matching your task. Clone the repository, install the development extras, and run an example from the repository root:
python -m pip install -e ".[dev]"
python examples/basic_board.py
| Goal | Example | Practical coverage |
|---|---|---|
| Build a normal board | basic_board.py | Minimal data, equal-width columns, editing, dragging, search, and change events. |
| Put database fields on cards | custom_fields.py | Every generated input type, custom title keys, display roles, validation, formatting, and visibility. |
| Load and save real rows | sqlite_board.py | SQLite cursors, key mappings, typed fields, and update/move persistence. |
| Own the card form | custom_editor.py | Disabled built-in editor, simple card callback, host-owned window, and update_card(). |
| Fetch without freezing Tk | async_loading.py | Background fetching, snapshots, loading state, and success/error delivery. |
| Explore everything together | example.py | Raw schemas, runtime field replacement, permissions, layout, text, theming, events, and snapshots. |
Use Field or a string for normal application code, CardField when you want an immutable typed value, and mapping definitions only when generating or serializing the complete low-level schema.
Tutorial 1: Static Board
Start from static Python data without any storage dependency. Disable dragging and omit persistence callbacks when changes only need to live for the lifetime of the window.
from ctk_kanban import Card, Column, CTkKanbanBoard
columns = [
Column(id="planned", title="Planned"),
Column(id="shipped", title="Shipped"),
]
cards = [
Card(
id="docs",
column="planned",
title="Write documentation",
priority="Medium",
tags=("documentation",),
)
]
board = CTkKanbanBoard(
app,
columns=columns,
cards=cards,
enable_drag=False,
confirm_delete=True,
)
Column and Card instances use the same validation as dictionaries. You can freely mix typed instances and mapping records in an input iterable.
Tutorial 2: Board Interaction
CTkKanban makes destructive and mutating actions visible and deliberate. Use config["actions"] or allow_card_deletion=False to remove operations that a particular application must not expose:
- Click a card body or its Edit action to select it and open the editor drawer.
- Save with Save changes, Enter outside a multiline textbox, or Ctrl+Enter anywhere in the editor.
- Press Escape or choose Cancel to close the drawer without saving.
- Drag from the handle in the card's upper-right corner. A six-pixel threshold prevents accidental drags.
- Use card menus to move up, move down, move between columns, edit, or delete.
- Use column menus to rename, move left, move right, or delete a column.
Dragging and positional menu actions are disabled while search is active, because hidden cards make the visible order ambiguous. Clear the search field before reordering cards.
Programmatic actions use the same model and event path as the built-in UI:
board.add_card(
{
"id": 42,
"column": "todo",
"title": "Review pull request",
"priority": "High",
"tags": ["code-review"],
},
index=0,
)
board.update_card(42, {"description": "Check tests and typing."})
board.move_card(42, "doing", index=0)
board.delete_card(42)
Tutorial 3: Save Changes
The on_change callback runs once after each successful mutation. Every event contains type, before, and the latest complete snapshot in data, plus the affected record.
import json
from pathlib import Path
BOARD_FILE = Path("board.json")
def save_change(event):
# Suitable for small local files. Avoid slow network or SQL work here.
BOARD_FILE.write_text(
json.dumps(event["data"], indent=2),
encoding="utf-8",
)
board = CTkKanbanBoard(
app,
columns=initial_data["columns"],
cards=initial_data["cards"],
on_change=save_change,
)
on_change runs on Tk's UI thread. For PostgreSQL, HTTP APIs, or any slow storage, enqueue the event for a worker instead of blocking inside the callback. Tk widgets must still only be accessed from Tk's thread.
Tutorial 4: Complete PostgreSQL App
This example uses Psycopg 3 mapping rows, a source-key mapping for the existing database column, and load_async() so database work does not freeze the desktop window.
Install Psycopg separately; CTkKanban intentionally does not require a database driver:
python -m pip install "psycopg[binary]"
Example schema
CREATE TABLE kanban_columns (
id text PRIMARY KEY,
title text NOT NULL,
position integer NOT NULL
);
CREATE TABLE kanban_cards (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
column_id text NOT NULL REFERENCES kanban_columns(id),
title text NOT NULL,
description text NOT NULL DEFAULT '',
priority text NOT NULL DEFAULT '',
tags text[] NOT NULL DEFAULT '{}',
position integer NOT NULL,
CHECK (priority IN ('', 'Low', 'Medium', 'High', 'Critical'))
);
Fetch and display
import os
import customtkinter as ctk
import psycopg
from psycopg.rows import dict_row
from ctk_kanban import CTkKanbanBoard, snapshot_from_rows
DATABASE_URL = os.environ["DATABASE_URL"]
COLUMN_QUERY = """
SELECT id, title
FROM kanban_columns
ORDER BY position
"""
CARD_QUERY = """
SELECT
id,
column_id,
title,
description,
priority,
tags
FROM kanban_cards
ORDER BY column_id, position
"""
def fetch_board():
with psycopg.connect(DATABASE_URL, row_factory=dict_row) as connection:
columns = connection.execute(COLUMN_QUERY).fetchall()
cards = connection.execute(CARD_QUERY).fetchall()
return snapshot_from_rows(
columns,
cards,
card_keys={"column": "column_id"},
)
app = ctk.CTk()
app.geometry("1200x720")
status = ctk.CTkLabel(app, text="Connecting…")
status.pack(fill="x", padx=16, pady=(12, 0))
board = CTkKanbanBoard(app)
board.pack(fill="both", expand=True)
board.load_async(
fetch_board,
on_success=lambda data: status.configure(
text=f"Loaded {len(data['cards'])} cards"
),
on_error=lambda error: status.configure(text=f"Load failed: {error}"),
)
app.mainloop()
The database uses column_id, while serialized card snapshots use column. The card_keys mapping translates that structural key without requiring a SQL alias. Use the same approach for database names such as task_id, status_id, or summary.
For plain tuple cursors, use rows_from_cursor() after each query. For writes, use stable IDs and positions, handle transactions in your repository layer, and feed the resulting canonical snapshot back through set_data() if the server changes any values.
Board Data Schema
A board snapshot is a mapping with exactly two keys:
{
"columns": [
{"id": "todo", "title": "To do"},
{"id": "done", "title": "Done"},
],
"cards": [
{
"id": 1,
"column": "todo",
"title": "Write release notes",
"description": "Summarize version 2.",
"priority": "Medium",
"tags": ["release"],
}
],
}
Unknown top-level snapshot keys and unknown column keys are rejected. Card mappings are intentionally extensible: schema-defined keys are normalized and other top-level card keys are deep-copied for round-tripping. Loading is atomic, so any invalid column, card, or relationship leaves the current model unchanged.
Column Records
| Field | Type | Required | Description |
|---|---|---|---|
id | str | int | Yes | Unique column identifier. Blank strings and booleans are rejected. |
title | str | Yes | Visible heading. Leading and trailing whitespace is removed; the result must not be blank. |
Column list order is the board's manual left-to-right order.
Card Records
| Field | Type | Required | Description |
|---|---|---|---|
id | str | int | Yes | Unique card identifier. Blank strings and booleans are rejected. |
column | str | int | Yes | ID of an existing column. column_id is accepted as an input alias. |
title | str | Yes | Nonblank card heading, trimmed during normalization. |
description | str | No | Plain text details. Defaults to an empty string. |
priority | str | No | One of empty, Low, Medium, High, or Critical. |
tags | Iterable[str] | No | Trimmed nonblank strings without commas. Snapshots return a new list. |
The fields above are the default schema. A custom schema may add any number of top-level values, while undeclared integration values are preserved without being edited, displayed, validated, formatted, or searched. Cards are serialized in column order and then in their manual top-to-bottom order. IDs should be stable database keys; use strings or integers when snapshots will be JSON encoded.
Card Field Schema
Pass fields to the board, model, or snapshot adapters to describe any number of card data points. Use the fluent Field builder for the common case, immutable CardField objects for typed definitions, or the existing mappings for full low-level control. A plain string creates a visible, editable, searchable text field and turns keys such as customer_name into the label Customer Name.
from ctk_kanban import CardField, Field
fields = [
Field("summary").label("Task").title(),
"customer_name",
Field("description").textarea().body(),
Field("stage")
.select(["Discovery", "Delivery", "Review"])
.badge(colors={"Delivery": "#22C55E"}),
CardField("due_date", label="Due", type="date"),
Field("estimate_hours").integer(minimum=0, maximum=100),
Field("internal_notes").textarea().editor_only(),
]
board = CTkKanbanBoard(
app, columns=columns, cards=cards, fields=fields
)
Field input helpers are text(), textarea(), number(), integer(), select(), multiselect(), date(), datetime(), checkbox(), and tags(). Display and behavior helpers include title(), body(), badge(), metadata(), card_only(), editor_only(), hide(), read_only(), required(), searchable(), section(), placeholder(), help(), default(), length(), validate(), and fmt(). build() returns a detached immutable CardField. All of these forms belong to the public FieldInput union.
Supported types
| Type | Editor control | Normalized value |
|---|---|---|
text | Single-line entry | Trimmed str; None becomes empty text. |
textarea | Multiline textbox | Trimmed str; None becomes empty text. |
number | Entry | float | None; booleans are rejected. |
integer | Entry | int | None; fractional float input is rejected. |
select | Option menu | One value; non-empty options are enforced. |
multiselect | Add/remove pills | Deduplicated list; non-empty options are enforced on save. |
date | Entry | ISO YYYY-MM-DD or empty text; accepts date input. |
datetime | Entry | ISO date-time or empty text; accepts datetime and trailing Z input. |
checkbox | Checkbox | Strict bool. |
tags | Add/remove tag pills | Deduplicated list[str]; trimmed, nonblank, comma-free items. |
hidden | Hidden by default | Deep-copied application value for schema-controlled data without UI. |
Date and date-time controls are text entries and validate on save. Multiselect is a compact add/remove input rather than a large checklist. Supply options for useful select editor choices; an empty sequence only means programmatic validation is unrestricted.
Definition options
Each low-level mapping requires a unique, nonblank key and label. Field, CardField, and string shorthand infer the label. Unknown options are rejected.
| Option | Default | Meaning |
|---|---|---|
type | "text" | One of the eleven supported type strings. |
required | False | Reject None, empty text, and empty lists. |
default | Type-dependent | Deep-copied when a configured key is absent. New controls use list [], checkbox False, numeric None, or empty text when no explicit default exists. |
placeholder | Empty | Entry hint; list inputs fall back to “Add a value”. |
options | () | Allowed sequence for select/multiselect. Empty means no model-level restriction. |
show_on_card | Role-dependent | Render on compact cards; true unless the inferred role is hidden. |
show_in_editor | Type-dependent | Generate a drawer control; false by default only for hidden fields. |
searchable | False | Include the field in case-insensitive substring search. |
read_only | False | Disable the generated control while retaining its value. |
section | "Details" | Drawer group; groups follow first appearance. Column selection lives in Organisation. |
card_role | metadata or hidden | Compact-card presentation role. |
help_text | Empty | Supporting copy; for checkbox controls it becomes the checkbox label. |
min, max | Unset | Inclusive numeric limits. |
min_length, max_length | Unset | Inclusive string character or list item limits. |
validator | Unset | (value, card) -> bool | str | None. False produces a generic error; a string is the error message. |
formatter | Unset | (value, card) -> str for compact display only; storage is unchanged. |
colors | {} | Exact value-to-color mapping for badge/accent or metadata pills. |
min cannot exceed max; length bounds are nonnegative and ordered. Validators run in field order during construction, loading, updates, editor save, and schema replacement, so keep them fast and side-effect free. A required read-only field should define a default so new cards can be created.
Compact-card roles
| Role | Presentation |
|---|---|
title | Main heading. Exactly one text/textarea definition uses this role; its key may match a source database column. |
body | Wrapped body line, truncated by card_description_max_chars. Multiple body fields are allowed. |
badge | Colored pill; the first non-empty visible badge also colors the accent strip. |
tags | One #value pill per item, capped per field by card_max_visible_tags. |
metadata | Pill formatted as Label: value. |
hidden | No compact-card output. |
Only show_on_card=True fields render, and empty values are omitted. A field colors match takes precedence; the built-in priority field otherwise uses priority theme tokens. formatter affects display text only.
Structural rules and runtime replacement
id,column, andcolumn_idare reserved and cannot be field keys.- If no supplied field uses the title role, the default
titledefinition is inserted first. - The chosen title may use a custom key, must be text or textarea, and is forced to required, visible-on-card, and the sole title role.
- Fields omitted from a loaded card are only materialized when they have an explicit default or are required; a new editor presents type defaults.
- Undeclared card keys are preserved as private application metadata.
get_fields() returns detached normalized definitions. set_fields(fields) validates every existing card before committing, rebuilds cards and an open drawer, and leaves the old schema/data untouched on failure. It emits one fields_changed event only when normalization actually changes stored card data. Treat changes that add defaults, coerce types, or narrow choices as data migrations.
Snapshots
get_data() returns detached dictionaries and lists. Mutating the returned value cannot accidentally mutate the live board.
snapshot = board.get_data()
snapshot["cards"].append({...}) # Does not change the widget.
board.set_data(snapshot) # Validates, replaces, and redraws.
set_data() does not emit on_change. This prevents a database refresh from being mistaken for a user edit and written straight back to storage.
Copy, replacement, and ordering guarantees
get_data(), modelsnapshot(), record getters, schema getters, and mutation return values are detached copies.set_data()validates the complete candidate first. Use{"columns": [], "cards": []}to clear the board.- Column order is left-to-right. Card order is top-to-bottom inside each column; global card output is grouped by current column order.
- Insertion indices are zero-based and may range from zero through the destination size, inclusive.
Noneappends. update_card()merges values rather than replacing the record. Changing its column appends;move_card()supports an exact position.- String and integer IDs are not coerced:
1and"1"are distinct. A card reference must have the same type as its column ID. - Input
column_idis normalized tocolumnin every output. Conflicting aliases are rejected.
Database Row Sources
CTkKanbanBoard.from_rows() creates a board directly from supported row iterables or executed DB-API cursors without taking a dependency on PostgreSQL, SQLite, SQLAlchemy, or another database library. Structural key mappings let query names remain unchanged.
from ctk_kanban import CTkKanbanBoard, Field
board = CTkKanbanBoard.from_rows(
app,
columns=column_cursor,
cards=card_cursor,
column_keys={"id": "status_id", "title": "status_name"},
card_keys={
"id": "task_id",
"column": "status_id",
"title": "summary",
},
fields=[
"customer_name",
Field("severity").badge(),
Field("due_date").date(),
],
)
Mapped source keys are consumed and normalized output uses canonical id, column, and title. Other selected database columns keep their names. Missing source columns and unsupported mapping targets raise clear errors. The lower-level snapshot helpers accept the same card_keys and column_keys options.
| Row source | Recommended helper | Notes |
|---|---|---|
Psycopg dict_row | from_rows() or snapshot_from_rows() | Rows already implement the mapping protocol. |
sqlite3.Row | from_rows() or snapshot_from_rows() | Converted through its keys() interface. |
SQLAlchemy Row | from_rows() or snapshot_from_rows() | Converted through row._mapping. |
| Plain DB-API tuples | from_rows() or rows_from_cursor() | Uses cursor.description for column names and consumes remaining rows. |
One reusable DB-API cursor
from ctk_kanban import rows_from_cursor, snapshot_from_rows
cursor.execute("""
SELECT id, title
FROM kanban_columns
ORDER BY position
""")
columns = rows_from_cursor(cursor)
cursor.execute("""
SELECT id, column_id AS column, title,
description, priority, tags
FROM kanban_cards
ORDER BY column_id, position
""")
cards = rows_from_cursor(cursor)
board.set_data(snapshot_from_rows(columns, cards, fields=fields))
Two executed cursors
from ctk_kanban import snapshot_from_cursors
snapshot = snapshot_from_cursors(columns_cursor, cards_cursor)
board.set_data(snapshot)
Result column names must be unique, so use SQL aliases for joins. A cursor must contain a SELECT result. PostgreSQL array columns work naturally for tags; JSON text must be decoded into a list before validation.
Feature: Editor Drawer
The built-in editor is an inspector-style drawer attached to the right side of the board. It keeps the board visible, reserves layout space at high display scaling, and only changes data after an explicit save. Its controls are generated from the active field schema.
| Control | Behavior |
|---|---|
| Text and textarea | Single-line or multiline text with required and length validation. |
| Number and integer | Typed numeric values with optional minimum and maximum constraints. |
| Select and multiselect | Configured choices, including optional empty selection. |
| Checkbox | Boolean values. |
| Date and datetime | ISO values; date inputs use YYYY-MM-DD. |
| Hidden | No control by default; the model can still retain and validate the value. |
| Column | Moves the card when a different destination is saved. |
| Tags | Add with Enter and remove from the visible tag pills. |
fields = [
{"key": "title", "label": "Title", "type": "text",
"required": True, "card_role": "title", "show_on_card": True},
{"key": "client", "label": "Client", "type": "text",
"searchable": True, "card_role": "metadata", "show_on_card": True},
{"key": "estimate", "label": "Estimate", "type": "integer", "min": 0},
{"key": "blocked", "label": "Blocked", "type": "checkbox"},
{"key": "due_date", "label": "Due date", "type": "date"},
]
board = CTkKanbanBoard(app, columns=columns, cards=cards, fields=fields)
Keyboard controls
| Key | Action |
|---|---|
Enter | Saves while focus is inside the editor, except when focus is in any multiline textbox. |
Ctrl+Enter | Saves from any field, including multiline textboxes. |
Escape | Closes the drawer without applying unsaved changes. |
Open the drawer directly when a surrounding application has its own buttons or shortcuts:
board.open_add_card_editor("todo")
board.open_edit_card_editor(card_id=17)
board.open_add_column_dialog()
Feature: Search
The toolbar search performs a case-insensitive substring match across every field marked searchable=True. The default schema searches title, description, priority, and every tag. Search filters only the rendered view; the underlying data and column counts stay intact.
board.search("critical")
board.search("database")
board.search("") # Clear the filter.
- Search does not emit a change event.
- The toolbar summary shows visible results and total cards.
- Empty columns display No matching cards.
- Drag and positional movement are disabled until search is cleared.
Feature: Selection
Clicking a card selects it and applies the configured selected border color. Selection is board-local and exposes a detached card record:
selected = board.get_selected_card()
if selected is not None:
print(selected["id"], selected["title"])
Deleting the selected card, or replacing the snapshot without that card, clears selection automatically. Selection itself is presentation state and does not emit on_change.
Feature: Dragging & Menus
Cards can be reordered within a column or moved between columns. The handle-only drag design preserves normal clicking and text interaction across the rest of the card.
| Action | Result | Event |
|---|---|---|
| Drag card handle | Moves to the indicated column and insertion position. | card_moved |
| Card menu: Move up/down | Changes top-to-bottom order in the current column. | card_moved |
| Card menu: Move to column | Appends to the chosen destination. | card_moved |
| Column menu: Move left/right | Changes the column's manual position. | column_moved |
Set enable_drag=False to remove card drag behavior while retaining movement through menus and the public API.
Feature: Change Events
Pass one on_change callback to observe every successful board mutation. Callback exceptions are logged under ctk_kanban and do not undo the in-memory change.
def board_changed(event):
print("Operation:", event["type"])
print("Before:", event["before"])
print("Current:", event["data"])
if "card" in event:
print("Affected card:", event["card"])
if "column" in event:
print("Affected column:", event["column"])
board = CTkKanbanBoard(app, on_change=board_changed)
| Event type | Additional keys | Triggered by |
|---|---|---|
card_added | card | add_card() or the add-card editor. |
card_updated | card, previous | A changed update_card() operation or editor save. |
card_deleted | card | delete_card() or confirmed menu deletion. |
fields_changed | fields | set_fields() when type/default normalization changes stored card values. |
card_moved | card, previous | Drag, menu movement, or move_card(). |
column_added | column | add_column() or the add-column dialog. |
column_updated | column, previous | A changed rename operation. |
column_deleted | column | delete_column() or confirmed menu deletion. |
column_moved | column | Menu movement or move_column(). |
All events also contain detached before and data snapshots. A column cascade exposes removed cards through before. No event is sent for a no-op update/move, a schema replacement that leaves stored values identical, selection, search, rendering, loading presentation, or set_data().
The board has already changed when on_change runs. Callback exceptions are logged and do not roll back automatically. Keep slow SQL/network work off the Tk thread; enqueue event["data"] to an application worker. If your policy requires immediate rollback, restore the detached event["before"] with set_data().
Feature: External Card Editor
Pass on_card_open to run a function when a card is clicked. It receives one detached card dictionary. Set use_builtin_editor=False when the callback should run without CTkKanban opening its generated drawer.
def card_clicked(card):
open_my_form(card)
board = CTkKanbanBoard(
app,
columns=columns,
cards=cards,
use_builtin_editor=False,
on_card_open=card_clicked,
)
Providing on_card_open also replaces the edit drawer when use_builtin_editor=True, preserving the original callback behavior. Use add_card() and update_card() when your form saves. Card schemas and fonts are shared across compact cards, and local search reuses existing card widgets rather than rebuilding them on every query.
Feature: Loading State & Async Loading
set_loading(True) updates the toolbar summary and disables toolbar inputs without changing board data. load_async() combines that state with safe background fetching and Tk-thread delivery.
thread = board.load_async(
fetch_board,
on_success=lambda snapshot: print(
f"Loaded {len(snapshot['cards'])} cards"
),
on_error=lambda error: print("Could not load:", error),
clear_on_error=False,
)
print(board.is_loading) # True while the request is pending.
print(board.load_error) # None, or the most recent async exception.
fetch_boardruns on a daemon worker thread and must return a board snapshot mapping.- The full snapshot is validated on the worker before it reaches the widget.
set_data(),on_success, andon_errorrun on Tk's thread.- Existing data stays visible after failure unless
clear_on_error=True. - Starting a newer load invalidates an older pending result so stale data cannot overwrite the newer request.
- A new request clears
load_error. A failed request stores the original exception beforeon_error. - Success applies data through
set_data(), so loading does not emiton_change. - Invalidating an old result does not forcibly stop its Python worker; it only prevents delivery.
- The returned
threading.Threadcan be inspected or joined by non-UI test code, but do not join it from Tk's UI thread.
The fetch callback may use database connections and pure Python conversion helpers. It must not read or configure Tk widgets. Use the success or error callback for UI updates.
Manual set_loading(True) only changes toolbar presentation. It does not create a worker, prevent API mutations, or alter data. Destroying the board invalidates pending delivery; attempting a new load after destruction raises RuntimeError.
Feature: Configuration & Permissions
config accepts a BoardConfig or a nested mapping. It keeps action availability, layout, user-facing labels, and confirmation policy separate from visual theme tokens.
board = CTkKanbanBoard(
app,
columns=columns,
cards=cards,
config={
"actions": {
"delete_cards": False,
"delete_columns": False,
"move_columns": False,
},
"layout": {
"show_toolbar": True,
"fill_columns": True,
"use_builtin_editor": True,
"column_width": 340,
"column_height": 560,
"editor_width": 480,
},
"text": {
"board_title": "Release planning",
"add_card": "+ New work item",
},
"confirm_delete": True,
},
)
Nested mappings may be partial. Dataclass defaults fill omitted values, while unknown names are rejected. The equivalent typed form uses frozen ActionConfig, LayoutConfig, TextConfig, and BoardConfig instances. merge_config() validates either representation.
Action permissions
Every action defaults to enabled.
| Setting | Built-in UI | Protected board API |
|---|---|---|
add_cards | Add-card buttons and drawer opening | add_card() |
edit_cards | Card opening/edit commands, including on_card_open | update_card() |
move_cards | Drag handle, card move menus, and drawer column changes | move_card(), plus column-changing update_card() |
delete_cards | Card delete command | delete_card() and non-empty column cascades |
add_columns | Add-column controls/dialog | add_column() |
edit_columns | Rename command | update_column() |
move_columns | Move-left/right commands | move_column() |
delete_columns | Column delete command | delete_column() |
A disabled public mutation raises BoardModelError; a disabled UI-opening helper simply returns. Disabling card deletion also blocks delete_column(..., delete_cards=True) when cards are present, so a cascade cannot bypass the policy.
Permissions do not restrict set_data(), set_fields(), or direct board.model calls. BoardModel has no action policy. Keep real authorization in the host application and expose the board API rather than its model when UI policy matters.
Layout and confirmation
| Setting | Default | Contract |
|---|---|---|
show_toolbar | True | Show title, summary, search, and add controls. Programmatic search() still works when hidden. |
enable_drag | True | Enable handle dragging only when move_cards is also true. Menus/API remain available when only drag is off. |
use_builtin_editor | True | Use the generated drawer. When false, on_card_open(card) can handle card clicks and add-editor opening is a no-op. |
fill_columns | False | Give columns equal shares of surplus width while retaining column_width as their scrolling minimum. |
column_width | 320 | Integer at least 220. |
column_height | 500 | Integer at least 240. |
editor_width | 420 | Integer at least 320. |
confirm_delete | True | Prompt only for built-in menu deletions. Direct deletion methods never prompt. |
Scrollbars use a 14-pixel default thickness and can be resized with the scrollbar_width theme token. The horizontal range is recalculated after host-window resizes, keeping every column reachable on narrower windows. Wheel input scrolls an overflowing column vertically; over a short column or the board background it moves horizontally. Shift+wheel always moves the board horizontally.
Customizable text
| Setting | Default |
|---|---|
board_title | "Board" |
search_placeholder | "Search cards…" |
add_card | "+ Add card" |
add_column | "Add column" |
no_columns | "No columns yet" |
no_columns_help | "Create a column to start planning" |
no_cards | "No cards yet" |
no_cards_help | "Add a card to get started" |
no_results | "No results" |
no_results_help | "Try another search" |
All text settings must be strings. Short mechanical labels inside menus and the editor are not currently configurable.
Override precedence
Direct constructor values override structured config only when explicitly supplied: show_toolbar, enable_drag, all three dimensions, confirm_delete, and board_title. allow_card_deletion and allow_column_deletion override the corresponding action settings. Remaining keyword arguments are forwarded to the outer CTkFrame.
Feature: Styling & Themes
CTkKanban derives its default surfaces, text, inputs, buttons, hover states, and scrollbars from the active CustomTkinter theme. Set appearance and color theme before creating the board:
import customtkinter as ctk
ctk.set_appearance_mode("dark")
ctk.set_default_color_theme("green")
board = CTkKanbanBoard(app, columns=columns, cards=cards)
Override only the tokens you need. A string fixes one color in both modes; a (light, dark) pair follows appearance mode.
purple_theme = {
"accent_color": ("#7C3AED", "#A78BFA"),
"selected_border_color": ("#7C3AED", "#A78BFA"),
"drop_indicator_color": ("#7C3AED", "#A78BFA"),
"column_accent_colors": (
("#2563EB", "#60A5FA"),
("#7C3AED", "#A78BFA"),
("#C11574", "#F472B6"),
),
"card_border_color": ("#CBD5E1", "#334155"),
}
board = CTkKanbanBoard(
app,
columns=columns,
cards=cards,
theme=purple_theme,
corner_radius=14, # Normal CTkFrame option for the board itself.
)
Theme tokens
| Token | Controls |
|---|---|
board_fg_color | Root board surface. |
toolbar_fg_color | Toolbar surface. |
column_fg_color | Column and empty-board surfaces. |
column_header_fg_color | Column header background. |
column_border_color | Column outlines and secondary controls. |
column_accent_colors | Cycled accent palette for column headers. |
card_fg_color | Card surface. |
card_hover_color | Card hover surface. |
dragging_card_fg_color | Card surface during dragging. |
card_border_color | Normal card outline. |
selected_border_color | Selected card outline. |
drop_indicator_color | Drag destination indicator. |
text_color | Primary text. |
muted_text_color | Descriptions, summaries, and placeholders. |
accent_color | Primary actions and accents. |
control_hover_color | Menu-like and secondary control hover state. |
count_fg_color | Column count badge surface. |
empty_icon_fg_color | Empty-board icon surface. |
editor_fg_color | Inspector drawer surface. |
editor_section_fg_color | Grouped editor section surface. |
divider_color | Editor separators. |
input_border_color | Search and editor input borders. |
scrollbar_color | Scrollbar thumb. |
scrollbar_hover_color | Scrollbar hover state. |
danger_color | Destructive actions and validation emphasis. |
pill_text_color | Priority and tag pill text. |
priority_low_color | Low-priority pill. |
priority_medium_color | Medium-priority pill. |
priority_high_color | High-priority pill. |
priority_critical_color | Critical-priority pill. |
tag_pill_colors | Cycled palette for tag pills. |
card_*_font, editor_*_font | Typography definitions supplied as CTkFont keyword mappings. |
*_corner_radius, *_border_width | Card, column, editor-section, input, and pill geometry. |
column_gap, card_gap, editor padding/gap tokens | Component spacing and density. |
card_description_max_chars, card_max_visible_tags | Compact-card content limits. |
input_height, textbox_height, scrollbar_width | Control dimensions. |
Complete 97-token index
DEFAULT_THEME exposes the exact current defaults. This index lists every accepted name so a misspelling never has to be discovered by trial and error.
| Area | All accepted tokens |
|---|---|
| Board surfaces | board_fg_color, toolbar_fg_color, column_fg_color, column_header_fg_color, column_border_color, column_accent_colors |
| Cards and drag | card_fg_color, card_hover_color, dragging_card_fg_color, card_border_color, selected_border_color, drop_indicator_color |
| Shared colors | text_color, muted_text_color, accent_color, control_hover_color, count_fg_color, empty_icon_fg_color, divider_color, danger_color |
| Editor/input/scroll colors | editor_fg_color, editor_section_fg_color, input_border_color, scrollbar_color, scrollbar_hover_color, error_text_color |
| Pills and priorities | pill_text_color, priority_low_color, priority_medium_color, priority_high_color, priority_critical_color, tag_pill_colors |
| Native menu colors | menu_fg_color, menu_text_color, menu_hover_color, menu_disabled_text_color |
| Board/toolbar geometry | board_padding_x, board_padding_y, toolbar_height, toolbar_corner_radius, toolbar_padding_x, toolbar_padding_y, toolbar_content_padding_y, search_width, button_height, control_corner_radius, small_control_size |
| Toolbar fonts | toolbar_title_font, toolbar_summary_font |
| Column geometry | column_corner_radius, column_border_width, column_gap, column_accent_height, column_header_padding_x, card_gap |
| Column fonts | column_title_font, column_count_font, column_empty_title_font, column_empty_body_font |
| Card geometry and limits | card_corner_radius, card_border_width, card_selected_border_width, card_accent_width, card_description_max_chars, card_max_visible_tags, pill_height, pill_corner_radius |
| Card fonts | card_title_font, card_body_font, card_metadata_font, pill_font |
| Editor layout | editor_border_width, editor_header_padding_x, editor_header_padding_y, editor_form_padding_x, editor_form_padding_y, editor_field_padding_x, editor_field_gap, editor_section_gap, editor_section_corner_radius, editor_section_border_width, editor_section_title_padding_y |
| Editor motion | editor_slide_step, editor_slide_interval_ms |
| Editor fonts | editor_eyebrow_font, editor_title_font, editor_status_font, section_title_font, field_label_font, help_text_font, status_text_font |
| Inputs and scrollbar | input_height, compact_input_height, input_corner_radius, input_border_width, textbox_height, scrollbar_width |
Color tokens accept CustomTkinter color strings or light/dark pairs. Font tokens are mappings passed to ctk.CTkFont(**mapping). Padding tokens may be scalars or the tuples used by the defaults. column_accent_colors and tag_pill_colors must be non-empty sequences because rendering cycles through them.
Unknown theme keys raise ValueError. Configure the complete appearance before constructing the board; changing a theme mapping later does not restyle widgets that already exist.
API: CTkKanbanBoard Constructor
CTkKanbanBoard(
master,
columns=(),
cards=(),
*,
on_change=None,
on_card_open=None,
theme=None,
fields=None,
config=None,
show_toolbar=None,
enable_drag=None,
use_builtin_editor=None,
fill_columns=None,
column_width=None,
column_height=None,
editor_width=None,
confirm_delete=None,
allow_card_deletion=None,
allow_column_deletion=None,
board_title=None,
**kwargs,
)
| Parameter | Type / default | Description |
|---|---|---|
master | CustomTkinter/Tk parent | Parent widget. |
columns | Iterable, () | Initial column mappings or Column instances in display order. |
cards | Iterable, () | Initial card mappings or Card instances in manual order. |
on_change | Callable | None | Receives one event dictionary after each changed mutation. |
on_card_open | Callable | None | Replaces the built-in existing-card editor and receives a detached card record. |
theme | Mapping | None | Partial overrides for known board theme tokens. |
fields | Iterable[FieldInput] | None | Strings, CardField, fluent Field, or mapping definitions used by validation, the generated editor, compact cards, and search. |
config | BoardConfig | Mapping | None | Structured action, layout, text, and deletion-confirmation configuration. |
show_toolbar, enable_drag, use_builtin_editor, fill_columns | bool | None | Optional direct overrides for the matching layout settings. |
column_width, column_height, editor_width | int | None | Optional direct size overrides. Defaults are 320, 500, and 420. |
confirm_delete | bool | None | Optional override for built-in menu confirmation. |
allow_card_deletion, allow_column_deletion | bool | None | Convenience permission overrides; disabled card deletion also blocks non-empty column cascades. |
board_title | str | None | Optional toolbar title override. |
**kwargs | CTkFrame options | Forwarded to customtkinter.CTkFrame, including size, corner radius, and root frame colors. |
CTkKanbanBoard.from_rows()
from_rows(master, columns, cards, *, card_keys=None, column_keys=None, fields=None, **board_options) consumes row iterables or DB-API cursors, maps source database columns, validates the complete snapshot, and returns a normal board widget. Card mappings accept canonical id, column, and title targets; board-column mappings accept id and title.
API: Board Methods
Data and rendering
| Method / property | Returns | Description |
|---|---|---|
get_data() | BoardSnapshot | Detached complete board state. |
set_data(data) | None | Atomically validate and replace the board without a change event. |
refresh(preserve_scroll=True) | None | Rebuild structural widgets, optionally retaining horizontal and per-column scroll positions. |
get_card(card_id) | CardRecord | None | Detached card, or None when the ID is unknown. |
get_cards(column_id=None) | list[CardRecord] | All cards in board order, or cards in one existing column. |
get_columns() | list[ColumnRecord] | Detached columns in manual order. |
get_fields() | list[dict] | Detached active field definitions. |
set_fields(fields) | None | Atomically validate and replace the schema, cards, and an open editor; emits fields_changed if normalization changes data. |
get_selected_card() | CardRecord | None | Current selection snapshot. |
search(query) | None | Apply or clear the view-only card search. |
Card mutations
| Method | Returns | Description |
|---|---|---|
add_card(card, *, index=None) | CardRecord | Add at a zero-based position in its column, or append. |
update_card(card_id, updates) | CardRecord | Update editable fields; column or column_id can also move it. |
move_card(card_id, column_id, index=None) | CardRecord | Move to a destination and insertion position, or append. |
delete_card(card_id) | CardRecord | Delete and return the removed record. |
Column mutations
| Method | Returns | Description |
|---|---|---|
add_column(column, *, index=None) | ColumnRecord | Add at a zero-based position, or append. |
update_column(column_id, updates) | ColumnRecord | Rename with an update mapping containing title. |
move_column(column_id, index) | ColumnRecord | Move to a zero-based manual position. |
delete_column(column_id, *, delete_cards=False) | ColumnRecord | Delete an empty column, or explicitly cascade its cards. |
Editors and loading
| Method / property | Returns | Description |
|---|---|---|
open_add_card_editor(column_id=None) | None | Open the add drawer, or do nothing when use_builtin_editor is false. With no columns, first opens the add-column dialog. |
open_edit_card_editor(card_id) | None | Open the built-in editor or call the configured custom callback. Unknown IDs are ignored. |
open_add_column_dialog() | None | Prompt for a title and add a UUID-backed column. |
is_loading | bool | Read-only property for pending asynchronous work. |
load_error | Exception | None | Most recent async load exception; reset when a new load starts. |
set_loading(loading) | None | Set loading presentation. Requires an actual boolean. |
load_async(fetch_snapshot, *, on_success=None, on_error=None, clear_on_error=False) | threading.Thread | Fetch and validate off-thread, then deliver through Tk. |
destroy() | None | Invalidate pending delivery, close the drawer/menu, release drag/scroll bindings, and tear down safely; repeated calls are ignored. |
Board mutation methods enforce ActionConfig and raise BoardModelError when disabled. They never show confirmation dialogs; confirm_delete applies only to built-in menu requests. No-op updates and moves return the normalized record without emitting an event.
API: BoardModel
BoardModel contains the same strict data and ordering behavior without importing or creating a Tk window. It is useful in repositories, tests, command-line tools, and service layers.
from ctk_kanban import BoardModel
model = BoardModel(columns=columns, cards=cards, fields=fields)
model.add_column({"id": "blocked", "title": "Blocked"})
model.move_card(card_id=1, column_id="blocked", index=0)
validated_snapshot = model.snapshot()
| Method | Description |
|---|---|
BoardModel(columns=(), cards=(), fields=None) | Construct and validate initial state with the default or supplied schema. |
snapshot() | Return detached serializable state. |
load(snapshot) | Atomically replace from a complete snapshot. |
load(columns=..., cards=...) | Atomically replace from explicit iterables. |
get_card(id) | Return a record; unlike the widget helper, an unknown ID raises BoardModelError. |
get_cards(column_id=None) | Return ordered card records. |
get_columns() | Return ordered column records. |
get_fields(), set_fields(fields) | Inspect or atomically replace field definitions. |
add_card(), update_card(), move_card(), delete_card() | Mutate cards with validation and manual ordering. |
reorder_card(card_id, index) | Move within the card's current column. |
add_column(), update_column(), move_column(), delete_column() | Mutate columns. Model update_column() also accepts a title string directly. |
clear() | Remove every column and card. |
update_card(card_id, updates=None, **changes) and update_column(column_id, updates=None, **changes) accept keyword changes in addition to a mapping; later keyword values win. Model update_column() also accepts a title string. load(data) requires exactly columns and cards, while load(columns=..., cards=...) is the explicit alternative; the two forms cannot be mixed.
The model returns detached records/snapshots, replaces data and schemas atomically, has no BoardConfig permissions, and emits no widget events. Invalid records, unknown IDs, duplicate IDs, out-of-range indices, protected nonempty column deletion, and unsupported updates raise BoardModelError, a subclass of ValueError.
API: Records & Types
| Name | Kind | Purpose |
|---|---|---|
Column | Frozen dataclass | Typed input with id and title. |
Card | Frozen dataclass | Typed input; stores tags as tuple[str, ...]. |
ColumnRecord | TypedDict | Serializable column output shape. |
CardRecord | dict[str, Any] | Serializable flat card output with structural and arbitrary custom keys. |
CardField | Frozen dataclass | Concise typed definition with visible, editable, searchable defaults and from_definition()/to_definition() conversion. |
Field | Fluent mapping builder | Configures card input type, compact role, visibility, validation, and formatting; build() returns a CardField. |
FieldDefinition | TypedDict | Editor, validation, search, and compact-display configuration for one value. |
FieldInput | Type alias | Accepted union of string, CardField, Field, and legacy field mappings. |
FieldType | Literal union | All supported field type strings. |
DEFAULT_FIELDS | Tuple of mappings | Default title, description, priority, and tags definitions. |
BoardSnapshot | TypedDict | Complete columns and cards output shape. |
BoardModelError | Exception | Validation and model-operation failure. |
ActionConfig | Frozen dataclass | Eight board mutation/action switches. |
LayoutConfig | Frozen dataclass | Toolbar, drag, and board/editor dimensions. |
TextConfig | Frozen dataclass | Stable application-facing board labels. |
BoardConfig | Frozen dataclass | Actions, layout, text, and delete-confirmation policy. |
DEFAULT_THEME | Dictionary | Import-time snapshot of all theme token defaults. |
__version__ | String | Installed CTkKanban package version. |
Definition helpers
from_definition() exposes model normalization without building an entire board:
column = Column.from_definition(
{"id": "todo", "title": " To do "}
)
card = Card.from_definition(
{
"id": 7,
"column_id": "todo",
"title": " Normalize me ",
"tags": [" docs ", "release"],
}
)
assert column.title == "To do"
assert card.column == "todo"
assert card.tags == ("docs", "release")
Card is intentionally a compatibility dataclass for the four default fields and cannot retain arbitrary schema keys. Use mapping records through BoardModel(fields=...) when custom values must survive normalization.
API: Data Adapters
| Function | Returns | Description |
|---|---|---|
normalize_row(row) | dict[str, Any] | Convert a mapping, SQLAlchemy-style _mapping row, or keys/index row into a plain dictionary. |
normalize_rows(rows) | list[dict[str, Any]] | Normalize an iterable of supported row objects. |
rows_from_cursor(cursor) | list[dict[str, Any]] | Consume a DB-API cursor and combine tuples with names from cursor.description. |
snapshot_from_rows(columns, cards, *, fields=None, card_keys=None, column_keys=None) | BoardSnapshot | Normalize row iterables or cursors, apply optional source-key mappings, and validate the complete board. |
snapshot_from_cursors(columns_cursor, cards_cursor, *, fields=None, card_keys=None, column_keys=None) | BoardSnapshot | Consume two executed cursors, apply optional mappings, and validate against the schema. |
rows_from_cursor() calls fetchall(), so it consumes all remaining result rows. It raises ValueError when there is no result metadata or when output column names are duplicated.
Adapters do not execute SQL, commit, close cursors, or own connections. Pass the same field schema as the board, and convert driver-specific JSON/array values in the repository layer when they are not already the expected Python values.
API: Theme Helpers
| Name | Description |
|---|---|
DEFAULT_THEME | A public dictionary snapshot of all discoverable token names and the CustomTkinter-derived defaults at import time. |
merge_theme(overrides=None) | Build a fresh default mapping from the currently active CustomTkinter color theme, validate override names, and return the merged dictionary. |
from ctk_kanban import DEFAULT_THEME, merge_theme
print(sorted(DEFAULT_THEME))
theme = merge_theme({
"accent_color": ("#2563EB", "#60A5FA"),
"danger_color": ("#B42318", "#F97066"),
})
merge_theme() deep-copies overrides where possible and rejects names outside DEFAULT_THEME. It does not type-check every visual value; malformed colors, font mappings, sizes, or empty palettes may be reported when CustomTkinter constructs the affected widget.
API: Errors & Lifecycle
| Boundary | Failure behavior |
|---|---|
| Board/model data and operations | BoardModelError, a ValueError subclass, for invalid records, schemas, IDs, indices, relationships, unknown records, duplicate IDs, unsupported updates, protected deletion, and disabled board actions. |
| Configuration | TypeError for incorrect kinds and ValueError for unknown options or invalid dimension bounds. |
| Theme names | ValueError for unknown tokens; value-shape failures may come from CustomTkinter during construction. |
| Row adapters | TypeError for an unsupported row, or ValueError for missing cursor metadata/duplicate result names; snapshot validation may raise BoardModelError. |
| Generated editor | Validation text appears in the drawer, focus moves to the failing control when possible, and unsaved data remains open. |
on_change / on_card_open | Callback exceptions are logged under ctk_kanban; an already-applied mutation is not automatically rolled back. |
| Async fetch | The original exception is stored in load_error and delivered to on_error. Success/error callbacks should handle their own failures. |
All widget construction, normal methods, and UI callbacks belong on Tk's main thread. Only load_async()'s no-argument fetch callable runs on a daemon worker; it must not touch Tk. Applying its snapshot and invoking its callbacks happens via the board's Tk event loop.
destroy() is idempotent. It increments the async generation so stale workers cannot deliver, cancels polling/editor animation callbacks, releases active menu and drag state, destroys managed scroll frames, and then tears down the outer widget. Starting load_async() after destruction raises RuntimeError.
Migrating from 1.x
Version 2 is intentionally incompatible with the broad 1.x framework. Treat the migration as a move to a focused widget with application-owned integration.
| 1.x concept | 2.0 replacement |
|---|---|
from CTkKanBan import ... | from ctk_kanban import ... |
| Dynamic field definitions | The focused fields schema and generated explicit-save drawer. |
| Inline editing | The explicit-save embedded editor, or the simple on_card_open(card) callback for your own behavior. |
| Mutation-specific callbacks | One on_change(event) callback. |
| Built-in persistence/data sources | Your repository layer plus snapshots, row adapters, and load_async(). |
| Advanced filtering and sorting | Simple local search and manual order; transform data in your application when needed. |
Many enable_* / show_* flags | Structured BoardConfig actions, layout, text, callbacks, and theme. |
| Extra custom record keys | Preserved directly; add a field definition when the value should be validated, edited, searched, or displayed. |
# Convert an application record before loading it.
card = {
"id": row["task_id"],
"column": row["status_id"],
"title": row["summary"],
"description": row.get("notes", ""),
"priority": row.get("priority", ""),
"tags": row.get("labels", []),
}
FAQ & Troubleshooting
CTkKanBan is the distribution name on PyPI. Version 2's module name is lowercase: from ctk_kanban import CTkKanbanBoard.
No database driver is built in. Query with Psycopg or your preferred library, create the widget directly with CTkKanbanBoard.from_rows(), or convert rows with snapshot_from_rows()/rows_from_cursor() before calling set_data() or load_async().
Your on_change callback runs on Tk's UI thread. Move slow SQL, network, retry, and conflict work to an application-owned worker or queue. Only deliver widget changes back on Tk's thread.
Priority is case-sensitive and must be exactly empty, Low, Medium, High, or Critical. Map database or API values before building the snapshot.
The model protects nonempty columns by default. Move or delete their cards first, or explicitly call delete_column(column_id, delete_cards=True). The built-in confirmed column menu uses the explicit cascade.
Search hides nonmatching cards, so the visible position is not a reliable full-list index. Clear search before dragging or using Move up and Move down. Direct API movement remains available if your application supplies an unambiguous index.
Yes. Supply fields for typed validation and generated controls. Arbitrary keys without definitions are also preserved for private integration metadata but are not shown or searched.
Call board.set_data(snapshot). Replacement validates and redraws but intentionally does not emit on_change.
rows_from_cursor() requires unique result names because duplicate keys cannot be represented safely in a dictionary. Alias joined columns in SQL, for example cards.column_id AS column.
Tags must be an iterable of strings—not one comma-separated string. Each tag is trimmed, must remain nonblank, and cannot contain a comma. PostgreSQL text[] values already arrive in a suitable list form with Psycopg.
Set CustomTkinter appearance and theme before constructing the board, and pass per-board overrides through theme. Existing child widgets are not rebuilt automatically when the original mapping changes; recreate or deliberately rebuild the board for a completely new palette.
Yes, when the fetch callback only performs non-Tk work. Fetching and validation happen on a worker; snapshot application and callbacks happen through the board's Tk event loop. Do not access widgets inside fetch_snapshot.
Open an issue in the CTkKanban GitHub repository with Python, CustomTkinter, and CTkKanban versions plus a minimal reproduction.