Intermediate

You have a Python list of dicts — maybe query results, CSV data, or API responses — and you need to show it to someone. Printing raw lists looks like a data dump. Writing custom string-formatting code for every project wastes time. What you actually want is a table: aligned columns, clear headers, and readable rows.

The tabulate library solves this in one function call. It takes any list of rows (lists, dicts, or dataframe-style data) and turns it into a clean table in any of 30+ formats: plain text, GitHub Markdown, HTML, LaTeX, and more. It is a zero-dependency pure-Python package installable in seconds.

This article covers everything you need to know: installing tabulate, formatting basic tables, controlling column alignment and number formatting, choosing output formats, using headers with dict data, handling missing values, and building a real-world reporting script. By the end you will be able to output professional-looking tables from any Python data structure with a single line of code.

Python tabulate: Quick Example

Before diving into details, here is tabulate in action — a complete working script you can run immediately:

# quick_tabulate.py
from tabulate import tabulate

data = [
    ["Alice",  "Engineering", 95000],
    ["Bob",    "Marketing",   72000],
    ["Carol",  "Engineering", 105000],
    ["Dave",   "HR",          68000],
]
headers = ["Name", "Department", "Salary"]

print(tabulate(data, headers=headers, tablefmt="grid"))
+-------+--------------+----------+
| Name  | Department   |   Salary |
+=======+==============+==========+
| Alice | Engineering  |    95000 |
+-------+--------------+----------+
| Bob   | Marketing    |    72000 |
+-------+--------------+----------+
| Carol | Engineering  |  105000  |
+-------+--------------+----------+
| Dave  | HR           |    68000 |
+-------+--------------+----------+

The tabulate() function takes your data as the first argument, an optional headers list, and a tablefmt string that controls the visual style. Numbers are right-aligned automatically, strings are left-aligned, and all columns are padded to match the widest value.

The sections below cover every format option, alignment control, and real-world use case you will encounter.

Python tabulate tutorial illustration 1
Raw print() statements: for people who enjoy suffering.

What Is tabulate and Why Use It?

tabulate is a Python library that converts tabular data (rows and columns) into formatted text tables. It was created by Sergey Astanin and has been downloaded hundreds of millions of times because it solves a universal problem: Python makes it easy to collect data but tedious to display it cleanly.

Compare the alternatives:

ApproachCode RequiredOutput QualityFormat Options
print() + f-stringsMany linesPoor (manual alignment)None
pandas to_string()Requires pandasGoodLimited
tabulate1 lineExcellent30+ formats
rich TableSeveral linesExcellent (colors)Terminal only

tabulate shines when you need output that can go into a terminal report, a Markdown README, an HTML email, or a LaTeX document — all from the same data structure with just a format string change. When you need colored terminal output, the rich library is a better choice; for everything else, tabulate is the fastest path to a readable table.

Installing tabulate

tabulate is a pure-Python package with no dependencies. Install it with pip:

pip install tabulate
Successfully installed tabulate-0.9.0

To use the WIDE_CHARS_MODE or CJK character support, install the optional extra:

pip install tabulate[widechars]

Import it with a single line: from tabulate import tabulate. The main function is also accessible as tabulate.tabulate if you prefer the module-style import.

Basic Usage: Lists, Tuples, and Headers

The simplest input is a list of lists (or list of tuples). Each inner list is one row, and each element in that list is a cell value. Pass headers as a list of column names to add a header row.

# basic_table.py
from tabulate import tabulate

# List of lists -- most basic input format
inventory = [
    ["Widget A", 150, 4.99],
    ["Widget B", 30,  12.50],
    ["Gadget X", 5,   99.00],
]

# Plain text -- no borders, just spacing
print(tabulate(inventory, headers=["Product", "Qty", "Price"]))
Product     Qty    Price
--------  -----  -------
Widget A    150     4.99
Widget B     30    12.5
Gadget X      5    99

Without a tablefmt argument, tabulate defaults to "simple" format: dashes separate the header from the data, and columns are space-padded. Notice that numeric columns are right-aligned automatically. String columns are left-aligned. This default behavior handles mixed data types correctly without any configuration.

Choosing a Table Format

The tablefmt parameter is where tabulate becomes powerful. Here are the most useful formats:

# formats_demo.py
from tabulate import tabulate

data = [["Python", 1991, "Guido van Rossum"],
        ["Rust",   2010, "Graydon Hoare"],
        ["Go",     2009, "Google"]]
h = ["Language", "Year", "Creator"]

# GitHub Markdown -- paste directly into README files
print("--- github ---")
print(tabulate(data, headers=h, tablefmt="github"))
print()

# Grid -- heavy borders, great for terminals
print("--- grid ---")
print(tabulate(data, headers=h, tablefmt="grid"))
print()

# HTML -- paste into web pages or emails
print("--- html ---")
print(tabulate(data, headers=h, tablefmt="html"))
print()

# Pipe -- GitHub/GitLab compatible Markdown
print("--- pipe ---")
print(tabulate(data, headers=h, tablefmt="pipe"))
--- github ---
| Language   |   Year | Creator         |
|------------|--------|-----------------|
| Python     |   1991 | Guido van Rossum|
| Rust       |   2010 | Graydon Hoare   |
| Go         |   2009 | Google          |

--- grid ---
+------------+--------+-----------------+
| Language   |   Year | Creator         |
+============+========+=================+
| Python     |   1991 | Guido van Rossum|
+------------+--------+-----------------+
| Rust       |   2010 | Graydon Hoare   |
+------------+--------+-----------------+
| Go         |   2009 | Google          |
+------------+--------+-----------------+

--- html ---
<table>
<thead>
<tr><th>Language</th><th style="text-align: right;">  Year</th><th>Creator</th></tr>
</thead>
<tbody>
<tr><td>Python  </td><td style="text-align: right;">  1991</td><td>Guido van Rossum</td></tr>
...
</tbody>
</table>

--- pipe ---
| Language   |   Year | Creator          |
|:-----------|-------:|:-----------------|
| Python     |   1991 | Guido van Rossum |
| Rust       |   2010 | Graydon Hoare    |
| Go         |   2009 | Google           |

The "html" format produces valid HTML table markup ready to paste into an email or web page. The "github" and "pipe" formats produce Markdown tables that render on GitHub, GitLab, and most Markdown renderers. The "grid" format uses + and - border characters and is the most readable in terminal output. Other available formats include "rst" (ReStructuredText), "latex", "tsv", "mediawiki", and many more — run tabulate.tabulate_formats to see the full list.

Python tabulate tutorial illustration 2
30+ table formats. One function call. Your boss thinks you worked hard.

Using Dictionaries as Input

If your data comes from a database query or API call, it is likely a list of dicts rather than a list of lists. tabulate handles this with the headers="keys" shortcut, which uses the dictionary keys as column headers automatically.

# dict_table.py
from tabulate import tabulate

employees = [
    {"name": "Alice",  "role": "Engineer",  "years": 4},
    {"name": "Bob",    "role": "Designer",  "years": 2},
    {"name": "Carol",  "role": "Manager",   "years": 8},
    {"name": "Dave",   "role": "Engineer",  "years": 1},
]

# headers="keys" extracts dict keys as column headers
print(tabulate(employees, headers="keys", tablefmt="simple"))
print()

# headers="firstrow" treats the first item as the header row
rows = [
    ["Name", "Role", "Years"],     # header row
    ["Alice", "Engineer", 4],
    ["Bob",   "Designer", 2],
]
print(tabulate(rows, headers="firstrow", tablefmt="grid"))
name     role         years
-------  ---------  -------
Alice    Engineer         4
Bob      Designer         2
Carol    Manager          8
Dave     Engineer         1

+-------+-----------+---------+
| Name  | Role      |   Years |
+=======+===========+=========+
| Alice | Engineer  |       4 |
+-------+-----------+---------+
| Bob   | Designer  |       2 |
+-------+-----------+---------+

The headers="keys" option is the most convenient for real-world data because it eliminates the need to maintain a separate header list. The column order follows the dict insertion order (guaranteed in Python 3.7+). If you need a different column order, build the rows as lists and pass an explicit headers list instead.

Number Formatting and Alignment

By default, integers and floats are right-aligned and displayed as-is. For financial or scientific data you often need specific decimal places or thousands separators. The floatfmt and intfmt parameters control this.

# number_format.py
from tabulate import tabulate

financial_data = [
    ["Q1", 1245890.50,  0.0834],
    ["Q2", 2312045.25,  0.1521],
    ["Q3", 987654.00,   0.0612],
    ["Q4", 3102450.75,  0.2341],
]

# floatfmt controls ALL float columns
# Use standard Python format specs
print(tabulate(
    financial_data,
    headers=["Quarter", "Revenue", "Growth"],
    tablefmt="grid",
    floatfmt=("", ",.2f", ".2%")   # tuple: different format per column
))
+----------+-------------------+----------+
| Quarter  | Revenue           | Growth   |
+==========+===================+==========+
| Q1       | 1,245,890.50      | 8.34%    |
+----------+-------------------+----------+
| Q2       | 2,312,045.25      | 15.21%   |
+----------+-------------------+----------+
| Q3       | 987,654.00        | 6.12%    |
+----------+-------------------+----------+
| Q4       | 3,102,450.75      | 23.41%   |
+----------+-------------------+----------+

When floatfmt is a tuple, each element applies to the corresponding column. An empty string "" means “use the default format”. This gives you per-column control: currency columns get comma separators and 2 decimal places, percentage columns get the % suffix. The format strings follow Python’s standard format() mini-language, so any valid format spec works here.

Python tabulate tutorial illustration 3
floatfmt=’.2f’ — because ‘1.0000000001’ is not a number, it’s a cry for help.

Handling Missing Values and None

Real data is messy. tabulate handles None and missing values gracefully with the missingval parameter, which lets you substitute a display string for any cell that is None.

# missing_values.py
from tabulate import tabulate

# Some cells are None -- common in database results
survey_data = [
    {"respondent": "User A", "age": 28,   "city": "Austin",   "score": 8.5},
    {"respondent": "User B", "age": None, "city": "Boston",   "score": None},
    {"respondent": "User C", "age": 34,   "city": None,       "score": 7.2},
    {"respondent": "User D", "age": 45,   "city": "Chicago",  "score": 9.0},
]

print(tabulate(
    survey_data,
    headers="keys",
    tablefmt="simple",
    missingval="N/A",      # replace None with "N/A"
    floatfmt=".1f"
))
respondent      age  city       score
----------  -------  -------  -------
User A           28  Austin       8.5
User B          N/A  Boston       N/A
User C           34  N/A          7.2
User D           45  Chicago      9.0

Without missingval, None cells render as the empty string, which can make columns look misaligned or confuse readers. Setting missingval="N/A" (or "--" or any string you prefer) makes the absence of data explicit and keeps the table easy to read. This is especially important when outputting to Markdown or HTML where empty cells can be visually ambiguous.

Controlling Column Alignment

While tabulate auto-aligns based on data type, you can override alignment explicitly with the colalign parameter. Valid values per column are "left", "right", "center", and "decimal".

# alignment.py
from tabulate import tabulate

scores = [
    ["Alice",   95,  "A"],
    ["Bob",     82,  "B"],
    ["Carol",   91,  "A"],
    ["Dave",    67,  "D"],
]

print(tabulate(
    scores,
    headers=["Student", "Score", "Grade"],
    tablefmt="pipe",
    colalign=("left", "right", "center")
))
| Student   |   Score | Grade   |
|:----------|--------:|:-------:|
| Alice     |      95 |    A    |
| Bob       |      82 |    B    |
| Carol     |      91 |    A    |
| Dave      |      67 |    D    |

In "pipe" format, the alignment is encoded in the separator line using :--, --:, and :-: syntax — the standard Markdown table alignment syntax. In other formats like "grid" or "simple", spaces are used for visual alignment. The "decimal" alignment option aligns numbers on the decimal point, which is useful for mixing integers and floats in the same column.

Python tabulate tutorial illustration 4
colalign=(‘left’,’right’,’center’) — the alignment you needed three sprints ago.

Real-Life Example: Git-Style Commit Log Reporter

Here is a practical script that simulates a commit log display tool — the kind of utility you would use in a CI pipeline script, a code review tool, or a command-line dashboard.

# commit_reporter.py
from tabulate import tabulate
from datetime import datetime, timedelta
import random

def generate_commit_log(num_commits=10):
    """Simulate a git commit log as a list of dicts."""
    authors = ["alice", "bob", "carol", "dave"]
    messages = [
        "Fix null pointer in auth module",
        "Add unit tests for parser",
        "Refactor database connection pool",
        "Update dependencies",
        "Implement retry logic for API calls",
        "Fix race condition in worker thread",
        "Add logging to file upload handler",
        "Optimize query for dashboard endpoint",
    ]
    commits = []
    base_date = datetime(2026, 4, 20)
    for i in range(num_commits):
        commit_date = base_date + timedelta(hours=i * 3)
        commits.append({
            "hash":    f"{random.randint(0, 0xFFFFFF):06x}",
            "author":  random.choice(authors),
            "date":    commit_date.strftime("%Y-%m-%d %H:%M"),
            "message": random.choice(messages)[:40],
            "files":   random.randint(1, 12),
        })
    return commits

def print_commit_summary(commits):
    """Print commit log in multiple formats."""
    print("=== RECENT COMMITS (terminal view) ===")
    print(tabulate(
        commits,
        headers="keys",
        tablefmt="simple",
        colalign=("left", "left", "left", "left", "right")
    ))
    print()

    # Stats summary
    by_author = {}
    for c in commits:
        by_author[c["author"]] = by_author.get(c["author"], 0) + 1

    stats = [[author, count, f"{count/len(commits)*100:.0f}%"]
             for author, count in sorted(by_author.items(), key=lambda x: -x[1])]

    print("=== AUTHOR BREAKDOWN ===")
    print(tabulate(stats,
                   headers=["Author", "Commits", "Share"],
                   tablefmt="github",
                   colalign=("left", "right", "right")))
    print()

    # Markdown export
    print("=== MARKDOWN (for README or PR description) ===")
    print(tabulate(commits, headers="keys", tablefmt="pipe"))

if __name__ == "__main__":
    commits = generate_commit_log(8)
    print_commit_summary(commits)
=== RECENT COMMITS (terminal view) ===
hash      author    date              message                                    files
--------  --------  ----------------  -----------------------------------------  -------
a3f2c1    alice     2026-04-20 00:00  Fix null pointer in auth module                   3
7b8e90    bob       2026-04-20 03:00  Add unit tests for parser                         7
...

=== AUTHOR BREAKDOWN ===
| Author   |   Commits | Share   |
|----------|-----------|---------|
| alice    |         3 | 38%     |
| bob      |         2 | 25%     |
| carol    |         2 | 25%     |
| dave     |         1 | 13%     |

=== MARKDOWN (for README or PR description) ===
| hash   | author   | date             | message                          |   files |
|--------|----------|------------------|----------------------------------|---------|
| a3f2c1 | alice    | 2026-04-20 00:00 | Fix null pointer in auth module  |       3 |
...

This script demonstrates three key strengths of tabulate: switching between terminal and Markdown formats without changing your data, using headers="keys" for dict input, and combining column alignment control with numeric formatting. Extend it to read from a real git log via subprocess.run(["git", "log", "--format=..."]) or a GitHub API response to build a fully functional commit reporter.

Python tabulate tutorial illustration 5
Three lines of code, one professional-looking report. Nobody needs to know.

Frequently Asked Questions

Can I use tabulate directly with a pandas DataFrame?

Yes. Pass the DataFrame to tabulate(df, headers=df.columns) or use the built-in df.to_markdown() which calls tabulate internally. For prettier output with an index, use tabulate(df, headers="keys", showindex=True). The showindex parameter controls whether the row index column appears in the output.

How do I truncate long cell values so the table does not overflow?

tabulate does not have a built-in truncation option. Pre-process your data before passing it in: use a list comprehension like [(row[0][:30], row[1]) for row in data] to clip string values to a maximum width. For terminal output, consider using the rich library’s Table class which has a built-in overflow setting per column.

Can I add color to tabulate output?

tabulate itself does not handle color. You can add ANSI color codes to cell values before passing them to tabulate (e.g., f"\033[91m{value}\033[0m" for red), but this can interfere with column width calculation. For colored terminal tables, the rich library is a better choice. tabulate’s strength is format portability — Markdown and HTML — not terminal styling.

Does tabulate sort data automatically?

No, tabulate outputs rows exactly as it receives them. Sorting is your responsibility before calling tabulate(). Use Python’s built-in sorted(data, key=lambda row: row[1], reverse=True) for lists, or sorted(data, key=lambda d: d["score"], reverse=True) for dicts. This separation of concerns is intentional — tabulate handles formatting only.

How do I save a tabulate table to a file?

tabulate returns a string, so save it like any string: open("report.txt", "w").write(tabulate(data, headers=headers)). For HTML output, wrap the HTML table string in a full HTML document template and save as .html. For Markdown, save as .md. For CSV export, use Python’s built-in csv module instead — tabulate is not designed for CSV.

Is there a way to set maximum column widths in tabulate?

Yes, in tabulate 0.9.0+ you can use the maxcolwidths parameter: tabulate(data, maxcolwidths=30) limits all columns to 30 characters and wraps longer content across multiple rows. Pass a list to set per-column widths: maxcolwidths=[20, None, 15] where None means unlimited. This is one of the most useful recent additions to the library.

Conclusion

tabulate is one of those libraries that solves a small but constantly recurring problem elegantly. You have seen how to format basic list and dict data, pick from 30+ output formats including plain text, Markdown, HTML, and LaTeX, control number formatting per column with floatfmt, handle missing values with missingval, and override alignment with colalign. The real-world commit reporter shows how these features combine into practical reporting tools.

The logical next step is to wire tabulate into a script that generates a report on a schedule — combine it with the csv module to read data, tabulate to format it, and email or Slack API to deliver it automatically. The official documentation at github.com/astanin/python-tabulate has a complete list of format names and options.