Beginner

You’ve written a Python CLI tool or a report generator, and your output looks great on your 4K monitor — then someone runs it in a terminal that’s 80 columns wide and the text spills across three lines, or you paste a multiline string into your code and the indentation is off because you indented it to match the function body. These are solvable problems, and Python’s built-in textwrap module is the tool for both of them.

textwrap ships with every Python installation and handles the most common text formatting tasks: wrapping long strings to a fixed width, removing excess indentation from multiline strings, truncating text with an ellipsis, and adding a consistent indent prefix. It’s especially valuable for CLI tools, log formatters, email body generators, and docstring utilities.

In this tutorial, you’ll learn how to use textwrap.wrap() and textwrap.fill() for line wrapping, textwrap.dedent() to strip indentation from multiline strings, textwrap.shorten() to truncate with an ellipsis, and textwrap.indent() to add prefix strings to text. By the end, you’ll have a solid text formatting toolkit for any Python output-heavy project.

Python textwrap: Quick Example

Here’s the module solving the three most common problems in a few lines:

# textwrap_quick.py
import textwrap

long_text = (
    "Python's textwrap module provides convenient functions for "
    "wrapping and filling text, as well as for dedenting and indenting "
    "text blocks. It is useful for formatting help messages and output "
    "in CLI tools, or for processing raw strings from user input."
)

# Wrap to 60 characters per line
wrapped = textwrap.wrap(long_text, width=60)
print("=== Wrapped (60 cols) ===")
for line in wrapped:
    print(line)

# Fill returns a single string instead of a list
print("\n=== fill() ===")
print(textwrap.fill(long_text, width=60))

# Shorten to 80 chars with ellipsis
print("\n=== Shortened ===")
print(textwrap.shorten(long_text, width=80, placeholder=" ..."))

Output:

=== Wrapped (60 cols) ===
Python's textwrap module provides convenient functions
for wrapping and filling text, as well as for dedenting
and indenting text blocks. It is useful for formatting
help messages and output in CLI tools, or for processing
raw strings from user input.

=== fill() ===
Python's textwrap module provides convenient functions
for wrapping and filling text, as well as for dedenting
and indenting text blocks. It is useful for formatting
help messages and output in CLI tools, or for processing
raw strings from user input.

=== Shortened ===
Python's textwrap module provides convenient functions for wrapping
and filling text, as well as for dedenting and indenting ...

textwrap.wrap() returns a list of strings (lines), while textwrap.fill() joins them with newlines and returns a single string. Use wrap() when you need to process individual lines; use fill() when you just want the formatted block. shorten() collapses whitespace and truncates at the nearest word boundary before the limit.

What Is the textwrap Module?

The textwrap module implements the “greedy” line-wrapping algorithm: it places as many words on each line as will fit within the specified width, then starts a new line. This matches how most word processors and terminal tools handle wrapping. It also normalizes whitespace — multiple spaces and embedded newlines are collapsed to a single space before wrapping, which makes it safe to use on raw strings that came from user input or text files.

FunctionReturnsUse Case
wrap(text, width)List of stringsWhen you need individual lines
fill(text, width)Single stringReady-to-print formatted block
dedent(text)Single stringStrip common leading whitespace
indent(text, prefix)Single stringAdd prefix to lines
shorten(text, width)Single stringTruncate with ellipsis
TextWrapperObjectReuse config across many wraps

Wrapping and Filling Text

Both wrap() and fill() accept the same set of keyword arguments for fine-grained control. The most useful ones are initial_indent (prefix for the first line), subsequent_indent (prefix for continuation lines), and break_long_words (what to do with words longer than the width).

# textwrap_wrap.py
import textwrap

description = (
    "Deploying to production: first, run the full test suite. "
    "Then update the CHANGELOG.md with release notes. "
    "Finally, tag the release and push to main. "
    "Rollback procedure: revert the tag and redeploy the previous artifact."
)

# Hanging indent (first line flush, rest indented)
hanging = textwrap.fill(
    description,
    width=65,
    initial_indent="",
    subsequent_indent="  ",
)
print("=== Hanging indent ===")
print(hanging)

# Bullet-point style
bullet = textwrap.fill(
    description,
    width=65,
    initial_indent="* ",
    subsequent_indent="  ",
)
print("\n=== Bullet point ===")
print(bullet)

# Handle words longer than width (e.g. long URLs)
url_text = "Documentation: https://docs.python.org/3/library/textwrap.html for full API reference."
print("\n=== Long word, break_long_words=True ===")
print(textwrap.fill(url_text, width=40, break_long_words=True))
print("\n=== Long word, break_long_words=False ===")
print(textwrap.fill(url_text, width=40, break_long_words=False))

Output:

=== Hanging indent ===
Deploying to production: first, run the full test suite.
  Then update the CHANGELOG.md with release notes.
  Finally, tag the release and push to main. Rollback
  procedure: revert the tag and redeploy the previous
  artifact.

=== Bullet point ===
* Deploying to production: first, run the full test
  suite. Then update the CHANGELOG.md with release notes.
  Finally, tag the release and push to main. Rollback
  procedure: revert the tag and redeploy the previous
  artifact.

=== Long word, break_long_words=True ===
Documentation: https://docs.python.or
g/3/library/textwrap.html for full
API reference.

=== Long word, break_long_words=False ===
Documentation:
https://docs.python.org/3/library/textwrap.html
for full API reference.

The hanging indent pattern is common in help text and changelogs: the first line is flush and subsequent lines are indented to align with the start of the text. Set break_long_words=False for URLs — splitting a URL mid-word makes it unclickable in terminals. The URL will overflow the column width, but that’s preferable to a broken link.

Dedenting Multiline Strings

textwrap.dedent() solves a common problem with multiline strings in Python: when you write a multiline string inside a function or class, you indent it to match the code, but that indentation becomes part of the string. dedent() strips the common leading whitespace from all lines, giving you a clean string without the code indentation baked in.

# textwrap_dedent.py
import textwrap

def generate_email_body(name, issue):
    # This multiline string is indented to match the function body
    # but that indentation would appear in the email output
    body = f"""
        Hi {name},

        We've received your report about: {issue}.

        Our team will review it within 24 hours.

        Best regards,
        Support Team
    """
    # dedent() removes the common indentation (8 spaces here)
    return textwrap.dedent(body).strip()

print(generate_email_body("Alex", "login not working"))

# Compare: without dedent
def broken_version(name):
    msg = """
        Hello {name}.
        This text has leading spaces on every line.
    """
    return msg  # indentation baked into the string

print(repr(broken_version("Alex")[:60]))
print(repr(generate_email_body("Alex", "test")[:60]))

Output:

Hi Alex,

We've received your report about: login not working.

Our team will review it within 24 hours.

Best regards,
Support Team

'\n        Hello {name}.\n        This text has leading spa'
'Hi Alex,\n\nWe\'ve received your report about: test.\n\nOur'

dedent() looks at all non-empty lines and strips the longest common leading whitespace prefix from every line. The .strip() call at the end removes the leading and trailing newlines that come from the triple-quoted string’s opening and closing lines. Combining dedent() + strip() is the idiomatic pattern for clean multiline strings in Python functions.

Adding Indentation with indent()

textwrap.indent(text, prefix) adds a prefix to the beginning of every line in the text. By default it applies to all lines; pass a predicate function to control which lines get the prefix. This is useful for adding quote markers to email replies, adding comment characters to code, or building nested structures.

# textwrap_indent.py
import textwrap

original = """Python was created by Guido van Rossum.
It was released in 1991.

The language emphasizes readability.
Its design philosophy is documented in the Zen of Python."""

# Add ">" prefix for email-style quoting
quoted = textwrap.indent(original, "> ")
print("=== Email quote ===")
print(quoted)

# Add "# " only to non-empty lines (skip blank lines)
commented = textwrap.indent(
    original,
    prefix="# ",
    predicate=lambda line: line.strip()  # True for non-empty lines
)
print("\n=== Code comment (skip blanks) ===")
print(commented)

# Nested indentation
inner = textwrap.fill("This is a long description of a feature.", width=40)
nested = textwrap.indent(inner, "    ")  # 4-space indent
print(f"\n=== Nested ===\nFeature:\n{nested}")

Output:

=== Email quote ===
> Python was created by Guido van Rossum.
> It was released in 1991.
>
> The language emphasizes readability.
> Its design philosophy is documented in the Zen of Python.

=== Code comment (skip blanks) ===
# Python was created by Guido van Rossum.
# It was released in 1991.

# The language emphasizes readability.
# Its design philosophy is documented in the Zen of Python.

=== Nested ===
Feature:
    This is a long description of a
    feature.

The predicate function receives each line (including the newline character) and returns True if the prefix should be added. Using lambda line: line.strip() evaluates to False for lines containing only whitespace or newlines — exactly what you want to skip blank lines. Without the predicate, blank lines get the prefix too, which produces > on otherwise empty lines.

Using the TextWrapper Class

When you need to wrap many strings with the same settings, instantiate textwrap.TextWrapper once and reuse it. This is more efficient than passing keyword arguments to fill() on every call, and it makes your configuration explicit and named.

# textwrap_wrapper.py
import textwrap

# Define a formatter for CLI help text
cli_formatter = textwrap.TextWrapper(
    width=72,
    initial_indent="  ",
    subsequent_indent="    ",
    break_long_words=False,
    break_on_hyphens=False,
)

commands = {
    "deploy": "Deploy the application to the target environment. Reads configuration from deploy.yaml in the project root.",
    "rollback": "Revert to the previous successful deployment. Specify --tag to rollback to a specific release tag.",
    "status": "Show the current deployment status, health check results, and last 5 deployment events.",
}

print("Available commands:\n")
for cmd, description in commands.items():
    print(f"  {cmd}")
    print(cli_formatter.fill(description))
    print()

Output:

Available commands:

  deploy
  Deploy the application to the target environment. Reads
    configuration from deploy.yaml in the project root.

  rollback
  Revert to the previous successful deployment. Specify
    --tag to rollback to a specific release tag.

  status
  Show the current deployment status, health check results,
    and last 5 deployment events.

break_on_hyphens=False prevents the wrapper from breaking hyphenated words like --tag across lines, which would make CLI flag names unreadable. The TextWrapper instance exposes all the same options as the module-level functions, but you configure them once and reuse the object.

Real-Life Example: CLI Report Formatter

This utility formats a structured data report for terminal output, handling variable-length text gracefully across different terminal widths.

# cli_report.py
import textwrap
import shutil

def format_report(title, items, terminal_width=None):
    """
    Format a list of (label, description) tuples as a terminal report.
    Automatically adapts to terminal width.
    """
    if terminal_width is None:
        terminal_width = shutil.get_terminal_size(fallback=(80, 24)).columns

    label_width = 20
    text_width = terminal_width - label_width - 4  # 4 for separator + padding

    separator = "-" * terminal_width
    wrapper = textwrap.TextWrapper(
        width=text_width,
        break_long_words=False,
        break_on_hyphens=False,
    )

    lines = [separator, title.center(terminal_width), separator]

    for label, description in items:
        wrapped_desc = wrapper.wrap(description)
        if not wrapped_desc:
            wrapped_desc = ["(no description)"]

        # First line: label + first line of description
        label_str = f"  {label:<{label_width - 2}}"
        lines.append(f"{label_str}  {wrapped_desc[0]}")

        # Continuation lines: blank label area + rest of description
        padding = " " * label_width
        for cont_line in wrapped_desc[1:]:
            lines.append(f"{padding}  {cont_line}")

    lines.append(separator)
    return "\n".join(lines)


# Sample report data
report_items = [
    ("Status", "All systems operational. Last health check passed 2 minutes ago."),
    ("Indexed Pages", "1 of 149 articles indexed by Google as of 2026-04-23. Recovery actions in progress."),
    ("Last Deploy", "2026-04-20 14:32 UTC -- deployed 5 new articles via REST API pipeline."),
    ("Open Issues", "Image backlog: 47 posts missing featured images. Category audit: 0 uncategorized posts remaining."),
]

print(format_report("== PythonHowToProgram.com Daily Status ==", report_items, terminal_width=78))

Output:

------------------------------------------------------------------------------
              == PythonHowToProgram.com Daily Status ==
------------------------------------------------------------------------------
  Status              All systems operational. Last health check passed 2
                      minutes ago.
  Indexed Pages       1 of 149 articles indexed by Google as of 2026-04-23.
                      Recovery actions in progress.
  Last Deploy         2026-04-20 14:32 UTC -- deployed 5 new articles via
                      REST API pipeline.
  Open Issues         Image backlog: 47 posts missing featured images.
                      Category audit: 0 uncategorized posts remaining.
------------------------------------------------------------------------------

The key technique here is the two-pass approach: format the first line of each item with the label, then use blank-label padding for all continuation lines. shutil.get_terminal_size() reads the actual terminal width, so the report adapts automatically whether someone runs it in a narrow SSH session or a wide desktop terminal.

Frequently Asked Questions

When should I use wrap() vs fill()?

Use wrap() when you need to process individual lines -- for example, to add line numbers, apply different formatting to the first line, or join them with a custom delimiter. Use fill() when you just need a ready-to-print string. fill(text, width) is literally equivalent to "\n".join(wrap(text, width)) -- it's just the common case wrapped in a convenience function.

Why do I need .strip() after dedent()?

dedent() only removes leading whitespace -- it doesn't remove the leading newline that a triple-quoted string gets from its opening """ followed by a newline, or the trailing newline before the closing """. The idiom textwrap.dedent(text).strip() handles both: dedent() removes the indentation on each line, and .strip() removes the leading and trailing blank lines. If you want to preserve intentional leading/trailing newlines, use .lstrip('\n') and .rstrip('\n') instead of .strip().

Does textwrap.fill() preserve existing newlines in the text?

No -- fill() and wrap() collapse all whitespace, including newlines, into single spaces before wrapping. If you have a paragraph-separated text and want to wrap each paragraph independently, split the text on double newlines first, wrap each paragraph, then rejoin. The TextWrapper class has a expand_tabs parameter (default True) and a fix_sentence_endings parameter that adds two spaces after sentence-ending punctuation -- but newlines are always normalized away.

Does textwrap handle Unicode and non-Latin text correctly?

For Latin-script languages, yes. For CJK (Chinese, Japanese, Korean) characters, the default width calculations assume each character is one column wide, but CJK characters are double-width in terminals -- so a 80-column wrap will visually overflow. The wcwidth library provides correct Unicode display width calculations; for CJK-heavy text, use it to calculate line widths manually rather than relying on textwrap.

Is textwrap efficient for large volumes of text?

For typical CLI output and report generation, performance is not a concern. For very large documents (hundreds of thousands of lines), the pure-Python implementation will be slower than tools written in C. In those cases, consider streaming the text through wrap() in chunks rather than wrapping the entire document at once. The TextWrapper class is reusable and avoids rebuilding the regex patterns on every call, which gives a modest speedup when wrapping thousands of strings with the same settings.

Conclusion

The textwrap module covers the four most common text formatting needs in Python: wrap()/fill() for line wrapping, dedent() for cleaning up multiline strings, indent() for adding prefix characters, and shorten() for truncation with an ellipsis. The TextWrapper class lets you configure these options once and reuse them efficiently. Combined with shutil.get_terminal_size() for adaptive terminal width, you have everything needed to build professional CLI output without third-party dependencies.

The CLI report formatter above is a ready-to-use starting point -- extend it with color support using the rich library, add a progress bar, or connect it to real data from your application's monitoring endpoints.

Official documentation: https://docs.python.org/3/library/textwrap.html