prettytable
prettytable turns rows of Python values into an ASCII table with plus signs and pipes, the kind you see in a MySQL shell. You make a PrettyTable, set field_names, push rows in, and print it. Beyond the default look it can emit Markdown, reStructuredText, Org mode, HTML, JSON, CSV, LaTeX, and MediaWiki from the same object, sort and filter rows at render time, wrap long cells to a width budget, and pull data in from a CSV file or a DB-API cursor. It is one class plus a handful of helper functions, with wcwidth as its only runtime dependency.
Still the least ceremony way to print an aligned table from a script, and the multi-format output is genuinely useful when the same rows need to reach a terminal, a wiki, and a JSON consumer. If your project already pulls in rich, use rich.table and skip this one.
Use it if
- You are writing a CLI or an admin script that prints a small result set and you want columns to line up without hand-rolling str.ljust arithmetic
- You need the same data in more than one shape: print an ASCII table for humans, then call get_json_string() or get_csv_string() for a machine, or set_style(TableStyle.MARKDOWN) to paste into a pull request
- Your rows contain CJK or emoji characters and naive padding breaks alignment; the wcwidth dependency exists specifically to measure display width instead of character count
- You want one dependency with no C extension and no transitive tree, which matters for scripts that run inside constrained or air-gapped environments
- You already depend on rich. rich.table does everything here plus colour, markup inside cells, live redraw, and terminal width detection, so adding prettytable means shipping a second table renderer that looks worse
- You are formatting a large result set. Column widths are computed by scanning every cell, and get_string() assembles the entire table into one string in memory, so it is the wrong tool for streaming a million rows to stdout
- The consumer is a machine. Borders and padding are pure noise to a downstream parser; write csv or json directly instead of asking prettytable to fake them
- You need real tabular data handling. There are no types, no aggregation, no joins, and no column arithmetic; every value becomes a string at render time, and sortby compares raw Python objects so a column mixing int and None raises TypeError
- You are stuck on Python 3.9 or older. Version 3.18 requires 3.10 or newer, so old runtimes have to pin an older release and stop getting fixes
Setup reality
pip install prettytable and you are done: it pulls only wcwidth, needs no compiler, and imports in milliseconds. The friction is API archaeology rather than installation. Tutorials and Stack Overflow answers from the 0.7 and early 3.x era use module level constants such as prettytable.MSWORD_FRIENDLY, prettytable.ALL, and prettytable.FRAME; those now emit deprecation warnings and the current spellings are TableStyle.MSWORD_FRIENDLY, HRuleStyle.ALL, and VRuleStyle.FRAME. Older material also references table.printt(), removed long ago. Two more things surprise people: styling attributes such as border and align persist on the object across prints until you reset them, and calling get_string(sortby=...) silently discards any dividers you added with add_divider(), because sorting reorders the rows the dividers were attached to.
Patterns
Build a table row by row and print itbuild-a-basic-table
from prettytable import PrettyTable
table = PrettyTable(["Service", "Replicas", "CPU"])
table.add_row(["api", 4, 0.75])
table.add_row(["worker", 2, 1.5])
table.add_rows([
["cron", 1, 0.1],
["web", 6, 0.25],
])
print(table)Field names can go in the constructor or via table.field_names. Every add_row must match the field count exactly or you get a ValueError, so build rows as lists, not zipped dicts.
Align columns and format numbers per columnalign-and-format-columns
from prettytable import PrettyTable
table = PrettyTable(["Region", "Users", "Revenue"])
table.add_row(["apac", 128394, 45210.5])
table.add_row(["emea", 9021, 1200.0])
table.align = "r" # every column
table.align["Region"] = "l" # one column
table.int_format["Users"] = ",d"
table.float_format["Revenue"] = ".2f"
table.none_format = "n/a"
print(table)Columns are centred by default, which looks wrong for numbers. int_format and float_format are old-style percent format specs applied per column, and they only fire when the cell is actually an int or float, not a numeric string.
Sort and filter at render timesort-and-filter-rows
print(table.get_string(sortby="Revenue", reversesort=True))
# persistent instead of per-call
table.sortby = "Users"
table.sort_key = lambda row: (row[0] is None, row[0])
# drop rows without touching the data
print(table.get_string(row_filter=lambda row: row[1] > 10000))
# only some columns, only some rows
print(table.get_string(fields=["Region", "Users"], start=0, end=5))sortby uses plain Python comparison, so a column holding both numbers and None raises TypeError; guard it with sort_key. Sorting also removes any dividers you added.
Switch to Markdown, RST, or a box-drawing styleapply-a-built-in-style
from prettytable import PrettyTable, TableStyle
table = PrettyTable(["Flag", "Meaning"])
table.add_row(["--dry-run", "print, do not apply"])
table.set_style(TableStyle.MARKDOWN)
print(table)
for style in (TableStyle.SINGLE_BORDER, TableStyle.RST, TableStyle.ORGMODE):
table.set_style(style)
print(table)
table.set_style(TableStyle.DEFAULT) # undoUse the TableStyle enum. The old module level names such as prettytable.MARKDOWN still work but emit a DeprecationWarning, and MSWORD_FRIENDLY, PLAIN_COLUMNS, DOUBLE_BORDER and RANDOM live on the enum too.
Emit JSON, CSV, HTML, or LaTeX from the same tableexport-other-formats
print(table.get_json_string())
print(table.get_csv_string())
print(table.get_html_string(attributes={"id": "report", "class": "zebra"}))
print(table.get_latex_string())
print(table.get_mediawiki_string())
# let the caller pick
def render(table, out_format="text"):
return table.get_formatted_string(out_format)get_formatted_string accepts text, html, json, csv, latex, and mediawiki and forwards extra keyword arguments to the underlying renderer. get_html_string(format=True) inlines CSS that mimics your border and alignment settings.
Fill a table from a CSV file or a DB-API cursorimport-from-csv-or-database
import sqlite3
from prettytable import from_csv, from_db_cursor, from_json
with open("export.csv") as fp:
table = from_csv(fp)
conn = sqlite3.connect("app.db")
cur = conn.cursor()
cur.execute("SELECT id, email, created_at FROM users LIMIT 20")
table = from_db_cursor(cur)
round_trip = from_json(table.get_json_string())from_db_cursor reads column names off cursor.description, so a driver that leaves description empty (some bulk or DDL statements) returns None instead of a table. from_csv takes an open file object, not a path.
Add a title and group rows into sectionsadd-title-and-dividers
from prettytable import PrettyTable
table = PrettyTable(["Stage", "Duration"])
table.title = "Build report\n2026-08-06"
table.add_row(["checkout", "3s"])
table.add_row(["install", "41s"], divider=True)
table.add_row(["test", "2m10s"])
table.add_divider()
table.add_row(["total", "2m54s"])
print(table)Titles may span lines with \n and each line is centred separately. add_divider() attaches the rule to the row above it, and any sort wipes dividers out.
Wrap long cells inside a width budgetcontrol-table-width
from prettytable import PrettyTable
table = PrettyTable(["Path", "Error"])
table.add_row([
"services/ingest/handlers/webhook.py",
"ConnectionResetError while flushing the batch to the upstream queue",
])
table.max_table_width = 80
table.max_width["Error"] = 40
table.min_width["Path"] = 12
table.break_on_hyphens = False
table.valign = "t"
print(table)Wrapping inserts real newlines inside cells, so the printed table is taller than the row count suggests; set valign to t or the wrapped text centres itself vertically and reads oddly.
Strip borders for grep-friendly outputborderless-output
from prettytable import PrettyTable, HRuleStyle, VRuleStyle
table = PrettyTable(["pod", "status"], border=False, header=True, padding_width=2)
table.add_row(["api-7f9", "Running"])
table.align = "l"
print(table)
# or keep the frame and drop only the inner rules
table.border = True
table.hrules = HRuleStyle.FRAME
table.vrules = VRuleStyle.NONE
print(table)hrules and vrules take the HRuleStyle and VRuleStyle enums now; the bare ALL and NONE names imported from prettytable are deprecated. preserve_internal_border=True keeps column separators while border=False.
Print a coloured table with ColorTablecolored-table
from prettytable.colortable import ColorTable, Theme, Themes
table = ColorTable(["Check", "Result"], theme=Themes.OCEAN)
table.add_row(["lint", "pass"])
table.add_row(["tests", "fail"])
print(table)
custom = Theme(default_color="37", vertical_color="34", junction_color="36")
table.theme = custom
print(table)ColorTable colours the border characters, not the cell contents. For coloured cells use custom_format with a colour library, and remember ANSI codes go into pipes and files unless you check sys.stdout.isatty() first.
Format cells with your own callablecustom-cell-formatting
from prettytable import PrettyTable
def human(field: str, value) -> str:
if field == "Bytes" and isinstance(value, int):
return f"{value / 1_048_576:.1f} MiB"
if value is None:
return "-"
return str(value)
table = PrettyTable(["File", "Bytes"])
table.custom_format = human
table.add_row(["dump.sql", 734003200])
table.add_row(["empty.log", None])
print(table)The callable receives (field_name, value) and must return a string. Set it as a dict keyed by column name to format only some columns; it runs after int_format and float_format, so do not set both on the same column.
Split a long table into pagespaginate-long-output
pages = table.paginate(page_length=20, line_break="\n\n--- more ---\n\n")
print(pages)
# add a running index column first if rows need numbering
table.add_autoindex("#")paginate returns one string with the separator embedded, not a list of pages, and it repeats the header on each page. The default separator is a form feed, which looks like garbage in most terminals.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| rich | PyPI | Your CLI wants colour, styled cell content, progress bars, and terminal-aware widths, not just aligned text. |
| tabulate | PyPI | You want a one-line function call over a list of lists or a DataFrame, with a wide menu of output formats and no object to configure. |
| texttable | PyPI | You want a tiny dependency-free renderer and per-column type hints for number formatting. |