Matplotlib pyplot.text(): Precise Plot Annotations in Python

I still remember the first time I shipped a production dashboard where a single missing annotation confused half the team. The chart looked “right,” yet the story was wrong because the key label wasn’t anchored where the data actually lived. That’s the day I stopped treating text on plots as decoration and started treating it as data. If you annotate in the wrong coordinate system, or you don’t align text correctly, you’re effectively lying to your reader.

In this post I’ll walk you through matplotlib.pyplot.text() as a practical, day‑to‑day tool for making your charts honest and readable. I’ll show you how to place text in data coordinates, how to align and rotate it, and how to avoid common mistakes that show up in real analytics work. You’ll see complete, runnable code, not fragments, and I’ll call out edge cases like dynamic axes, overlapping labels, and performance at scale. By the end, you’ll have a clear mental model for when to use text(), when not to, and how to make annotations that survive resizing, scaling, and quick iterations.

The mental model: text is just another artist

In Matplotlib, plt.text() creates a Text artist and adds it to the current Axes. That matters because artists share the same coordinate system and transformation pipeline as lines, bars, and scatter points. If you keep that mental model in mind, the rest becomes straightforward: you specify a position, you choose a coordinate system, and you configure the artist’s properties.

By default, x and y are in data coordinates, which means the text moves when the data limits change. That’s perfect for labeling a data point or annotating a threshold. But sometimes you want the label to be fixed relative to the axes or figure—like a watermark, a plot title in a custom location, or a “draft” tag. In those cases you change the coordinate system using the transform parameter.

Here’s the function signature I keep at hand:

matplotlib.pyplot.text(x, y, s, fontdict=None, kwargs)

  • x, y are the coordinates for the text location.
  • s is the string to display.
  • fontdict can override font properties in a single dict.
  • kwargs are standard text properties (color, size, alignment, rotation, and much more).

It’s a simple signature, but the power comes from the transforms and the text properties. I’ll unpack those next.

A minimal, correct annotation with data coordinates

When you annotate a specific value on a plot, you typically want the text to follow the data. The default coordinate system is data coordinates, so the simplest code already does the right thing.

Here’s a complete example with a line plot and a point annotation:

import numpy as np

import matplotlib.pyplot as plt

Data

x = np.arange(1, 6)

y = np.array([2.1, 2.4, 2.0, 3.2, 3.8])

plt.figure(figsize=(7, 4))

plt.plot(x, y, marker="o", color="#2a6f97", linewidth=2)

Annotate the maximum value

max_idx = np.argmax(y)

maxx = x[maxidx]

maxy = y[maxidx]

plt.text(

max_x,

max_y,

"Peak",

fontsize=12,

color="#1b4332",

ha="left",

va="bottom",

)

plt.title("Weekly Conversion Rate")

plt.xlabel("Week")

plt.ylabel("Rate (%)")

plt.tight_layout()

plt.show()

A few choices here matter:

  • I anchor the text to the data point with maxx and maxy.
  • I use ha="left" and va="bottom" so the text doesn’t overlap the marker.
  • I keep the font size moderate; oversized text usually harms readability unless it’s a headline label.

This is a small example, but it shows the baseline: data coordinates are great when the label should travel with the data.

Aligning text correctly: small changes, big impact

Misaligned text is one of the most common issues I see in charts. It’s also one of the easiest to fix once you understand the ha (horizontal alignment) and va (vertical alignment) properties.

Alignment determines how the text is anchored relative to the given (x, y) position. The default is ha="left" and va="baseline", which often causes unintended overlap with markers or lines.

Here’s a quick demo you can run to see alignment differences:

import matplotlib.pyplot as plt

plt.figure(figsize=(6, 6))

plt.plot([0.5], [0.5], marker="o", color="#2a6f97")

plt.text(0.5, 0.5, "center", ha="center", va="center", fontsize=12, color="#1f2937")

plt.text(0.5, 0.5, "left/bottom", ha="left", va="bottom", fontsize=12, color="#9b2226")

plt.text(0.5, 0.5, "right/top", ha="right", va="top", fontsize=12, color="#005f73")

plt.xlim(0, 1)

plt.ylim(0, 1)

plt.title("Text Alignment Anchors")

plt.show()

In practice:

  • Use ha="center", va="center" when you want the text to sit directly on a point (for example, labeling a dot in a scatter plot).
  • Use ha="left", va="bottom" when you want the label to sit just above and to the right.
  • Use ha="right", va="top" when labeling a point from the upper-right direction.

I treat alignment like text gravity. It’s a small setting, but it decides whether your labels float nicely or crash into your data.

Changing coordinate systems with transform

Sometimes you want the text to stay put regardless of the data. That’s where transforms come in. The most common alternatives to data coordinates are:

  • Axes coordinates: (0, 0) is bottom-left of the axes, (1, 1) is top-right.
  • Figure coordinates: (0, 0) is bottom-left of the figure, (1, 1) is top-right.

You switch with transform=. Here’s a runnable example that shows three text placements using different coordinate systems:

import matplotlib.pyplot as plt

import numpy as np

x = np.linspace(0, 10, 100)

y = np.sin(x)

fig, ax = plt.subplots(figsize=(7, 4))

ax.plot(x, y, color="#2a6f97")

Data coordinates (default)

ax.text(2, 0.8, "Data coords", color="#1b4332", fontsize=11)

Axes coordinates

ax.text(

0.02,

0.95,

"Axes coords",

transform=ax.transAxes,

color="#9b2226",

fontsize=11,

ha="left",

va="top",

)

Figure coordinates

fig.text(

0.98,

0.02,

"Figure coords",

color="#005f73",

fontsize=10,

ha="right",

va="bottom",

)

ax.set_title("Transform Examples")

plt.tight_layout()

plt.show()

I recommend using axes coordinates for things like panel labels (“A”, “B”, “C”), chart corner notes (“draft”), or annotations that shouldn’t move if you zoom or filter the data. For watermark text, figure coordinates often make more sense so the label stays consistent across multiple subplots.

Styling text: fontdict, kwargs, and rcParams

The fontdict argument exists for historical reasons, but I almost always use keyword arguments for readability. You can still use fontdict when you want to reuse a consistent style across many calls.

Here’s a simple style bundle using fontdict:

import matplotlib.pyplot as plt

style = {

"fontsize": 13,

"color": "#0f172a",

"fontweight": "semibold",

}

plt.figure(figsize=(6, 3))

plt.plot([1, 2, 3], [1, 4, 2], color="#2a6f97")

plt.text(1.5, 3.5, "Styled label", fontdict=style, ha="center")

plt.title("fontdict Example")

plt.show()

When I’m doing production plots, I usually set base styles via rcParams or a custom style sheet and then use plt.text() for local overrides. That keeps styling consistent across charts.

Key text properties I reach for most often:

  • fontsize: use 10–12 for labels and 14–18 for major callouts in typical 700–900px wide charts.
  • color: prefer muted colors that match your palette.
  • fontweight: “bold” or “semibold” for emphasis.
  • alpha: subtle transparency if text needs to fade into the background.
  • rotation: for angled labels or to match slope.

Annotating a point with offset and arrow (text vs annotate)

When you want a label to point to a specific data point with an arrow, plt.annotate() is the tool of choice. But plt.text() can still be part of that workflow when you want the label to be independent of arrow mechanics.

I often use text() for the label and a separate line or arrow for context. Here’s a clean pattern:

import numpy as np

import matplotlib.pyplot as plt

x = np.arange(1, 6)

y = np.array([1.2, 2.5, 2.0, 3.6, 2.9])

fig, ax = plt.subplots(figsize=(7, 4))

ax.plot(x, y, marker="o", color="#2a6f97")

point_x = 4

pointy = y[pointx – 1]

Draw a thin connector line

ax.plot([pointx, pointx + 0.4], [pointy, pointy + 0.4], color="#6b7280", linewidth=1)

Place the text at the end of the connector

ax.text(

point_x + 0.45,

point_y + 0.45,

"Spike from campaign",

fontsize=11,

color="#111827",

ha="left",

va="bottom",

)

ax.set_title("Annotation with Connector")

ax.set_xlabel("Week")

ax.set_ylabel("Revenue ($k)")

plt.tight_layout()

plt.show()

This gives you full control over positioning and alignment. If you instead used annotate, you’d get arrow styling built in, which is great, but text() keeps the text and line loosely coupled—useful when you need different transforms or layouts.

Handling overlaps and crowded labels

When your plot is dense, labels will collide. A simple strategy is to stagger labels or place them in axes coordinates with a small data-derived offset. I often do a quick pass to detect nearby points and offset their labels by a fixed amount.

Here’s a pattern that offsets labels vertically to reduce overlap, still using text():

import numpy as np

import matplotlib.pyplot as plt

x = np.arange(1, 9)

y = np.array([3.2, 3.1, 3.3, 3.25, 3.28, 3.2, 3.22, 3.27])

fig, ax = plt.subplots(figsize=(7, 4))

ax.plot(x, y, marker="o", color="#2a6f97")

for i, (xi, yi) in enumerate(zip(x, y)):

# Alternate offset up and down

offset = 0.08 if i % 2 == 0 else -0.08

ax.text(

xi,

yi + offset,

f"{yi:.2f}",

fontsize=9,

color="#111827",

ha="center",

va="center",

)

ax.set_title("Alternating Label Offsets")

ax.set_xlabel("Sample")

ax.set_ylabel("Score")

plt.tight_layout()

plt.show()

For more complex overlap management, I usually reach for automated layout tools or adjust label placement manually in a pass. But as a quick fix, a small offset or alternating placement solves most crowded label problems without extra dependencies.

Common mistakes and how I avoid them

Here are the mistakes I see most often with plt.text() and the fixes I apply immediately:

1) Wrong coordinate system

If your labels drift when you zoom or change limits, you probably need axes coordinates. Use transform=ax.transAxes for fixed placement.

2) Misaligned text

Default alignment can put the text directly on top of your marker. Always set ha and va deliberately.

3) Overlapping labels

If you label every point, you will overlap. Stagger labels, label only key points, or move labels to a small offset.

4) Hidden text due to clipping

Text can get clipped if it sits outside the axes. Set clip_on=False if you truly need it to extend beyond the plot area, but use this sparingly.

5) Inconsistent styling

A single random font size or color draws attention to the wrong detail. Set a consistent palette and type scale, and override only when necessary.

When to use text vs when not to

Use plt.text() when:

  • You need a label anchored to specific data points.
  • You want quick annotations without arrows.
  • You want precise control over positioning and alignment.

Avoid plt.text() when:

  • You need label collision management across hundreds of points (use specialized libraries or fewer labels).
  • You need arrowed callouts with complex positioning (use annotate).
  • You are writing interactive plots that require dynamic tooltips (consider interactive libraries in that case).

I’m not saying text() can’t handle big jobs. It can. But your time is better spent using the right tool for the job, especially when you’re working in teams or shipping dashboards quickly.

A practical, real-world example: annotating a sales chart

Here’s a more realistic chart with multiple labels, styling, and transforms. You can run this as-is:

import numpy as np

import matplotlib.pyplot as plt

months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun"]

sales = np.array([48, 52, 45, 60, 58, 66])

fig, ax = plt.subplots(figsize=(8, 4.5))

ax.plot(months, sales, marker="o", color="#2a6f97", linewidth=2)

Label the best month

best_idx = np.argmax(sales)

ax.text(

best_idx,

sales[best_idx],

"Best month",

fontsize=11,

color="#1b4332",

ha="left",

va="bottom",

)

Add a fixed note in axes coords

ax.text(

0.02,

0.98,

"Mid-year run-rate",

transform=ax.transAxes,

fontsize=10,

color="#6b7280",

ha="left",

va="top",

)

Display values above each marker

for i, val in enumerate(sales):

ax.text(i, val + 1.2, f"{val}k", fontsize=9, color="#111827", ha="center")

ax.set_title("Sales Trend (First Half)")

ax.set_ylabel("Revenue ($k)")

ax.grid(axis="y", alpha=0.2)

plt.tight_layout()

plt.show()

A couple of points I recommend:

  • Use subtle gridlines to keep focus on the data and labels.
  • Keep the data labels small and consistent so they don’t compete with the main annotation.
  • Stick to a palette so text colors feel intentional.

Performance considerations for large plots

Text rendering is surprisingly expensive. If you try to label thousands of points, you’ll notice slow figure rendering and export times. On modern machines, hundreds of labels are fine, but thousands can push render times into the hundreds of milliseconds or even seconds, especially in large figures.

I typically follow these rules:

  • For more than 200 points, label only key points (min, max, recent, or outliers).
  • Use smaller font sizes and simpler styles to reduce rendering cost.
  • Avoid extra path effects or complex bounding boxes unless necessary.

If you absolutely must label many points, consider aggregating or using interactive tooltips in an interactive plotting stack. For static Matplotlib figures, text() is best when you’re selective.

Edge cases: log scales, inverted axes, and aspect ratios

text() respects whatever transforms are on the axes. That’s a good thing, but it can surprise you if you forget.

  • Log scales: If your axis is log-scaled, text in data coords will be placed in log space. That’s usually right, but it can make “linear” offsets look non-linear. Use small multiplicative offsets (like 1.05x) instead of additive offsets.
  • Inverted axes: If you invert the axis, the visual direction of “up” and “down” changes. Use alignment rather than offsets so your labels still appear in the right place.
  • Fixed aspect ratios: When you set ax.set_aspect(‘equal‘), distances in x and y are comparable. This can compress text relative to the plot area. You may need to adjust font sizes or offsets.

Here’s a quick example on a log scale, using multiplicative offset:

import numpy as np

import matplotlib.pyplot as plt

x = np.array([1, 2, 5, 10, 20])

y = np.array([10, 30, 100, 250, 800])

fig, ax = plt.subplots(figsize=(7, 4))

ax.plot(x, y, marker="o", color="#2a6f97")

ax.set_xscale("log")

ax.set_yscale("log")

for xi, yi in zip(x, y):

ax.text(

xi,

yi * 1.1,

f"{yi}",

fontsize=9,

color="#111827",

ha="center",

va="bottom",

)

ax.set_title("Log-Scale Labels with Multiplicative Offset")

plt.tight_layout()

plt.show()

Multiplying yi by 1.1 keeps the offset consistent in log space, which is far more readable than adding a fixed amount.

Tables: traditional vs modern annotation practices

When teams work on multiple charts, I see two approaches. Here’s the comparison I recommend using when you’re deciding how to annotate in 2026 workflows.

Traditional vs Modern Annotation Methods

Traditional Approach | Modern Approach

Manual, per-plot styling | Shared style dictionaries or rcParams presets

Fixed data labels for all points | Selective labeling with consistent heuristics

Annotations added last | Annotations planned as part of the chart design

Hard-coded offsets | Coordinate-aware offsets using transforms

Static-only review | Quick visual checks in CI with saved PNG diffs

The “modern” side isn’t about more tools. It’s about building repeatable habits and using small helpers to reduce manual work. Even in Matplotlib, you can automate annotation patterns with a few utility functions and keep your plots consistent across reports.

Real-world scenario: annotating anomalies in monitoring data

If you monitor system metrics, you often want to tag anomalies directly on the plot. Here’s how I do it with text(), keeping the labels concise and readable:

import numpy as np

import matplotlib.pyplot as plt

np.random.seed(0)

minutes = np.arange(0, 60)

latency = 120 + 5 * np.random.randn(60)

latency[20] = 180

latency[45] = 200

fig, ax = plt.subplots(figsize=(8, 4.5))

ax.plot(minutes, latency, color="#2a6f97")

Mark anomalies

anomalies = [20, 45]

for m in anomalies:

ax.text(

m,

latency[m],

"Anomaly",

fontsize=10,

color="#9b2226",

ha="left",

va="bottom",

)

ax.set_title("Latency with Anomaly Tags")

ax.set_xlabel("Minute")

ax.set_ylabel("Latency (ms)")

plt.tight_layout()

plt.show()

This pattern works well in monitoring dashboards or post-incident reports. If you need more detail, pair the text with a custom line, or use annotate() for an arrow.

Practical guidance I follow every time

Here’s the short checklist I use when I add text to a plot:

  • Decide the coordinate system first. If the text should move with data, use data coords. If it should be fixed on the axes, use transform=ax.transAxes.
  • Set alignment explicitly. It avoids most overlap and makes the chart more stable.
  • Keep text sizes consistent. Use one size for labels, another for callouts.
  • Keep labels short. If you need a paragraph, you probably need a separate note or caption.
  • Test with resized figures. A label that looks fine at 800px may collide at 500px.

This sounds basic, but a consistent process makes your plots look like they were designed, not patched.

Key takeaways and next steps

If you want a plot to tell the truth, your labels must be just as precise as your data. matplotlib.pyplot.text() is the simplest way to do that, but you get the best results only when you treat text as a first‑class plotting element. I always start by choosing the right coordinate system, then I anchor the label with alignment and small offsets. If I need fixed positioning, I use axes or figure coordinates. If I need data-bound labels, I keep them close to the data and consistent in style.

When you add many labels, performance and readability become the limiting factors. In practice, that means you should label only what matters: peaks, thresholds, anomalies, or key summary points. When I need to label every point, I keep labels tiny and consider offsets or staggered placement. For log scales and inverted axes, I use multiplicative offsets and rely on alignment rather than guesswork.

The next step I recommend is to build a small annotation helper in your codebase—something like add_label(ax, x, y, text, mode="data")—so you don’t reinvent your styling every time. That keeps your reports consistent and reduces last‑minute tinkering. If you’re producing charts regularly, that little helper becomes the difference between labels that are “good enough” and labels that are clear, stable, and trustworthy.

If you want, I can draft a reusable annotation utility for your specific plotting style and data patterns, or review one of your existing charts to show where text() would make it clearer.

Scroll to Top