Plotting Text Over Images and Charts in Jupyter
Most people hitting this wall are trying to put labels, annotations, or captions directly on top of a matplotlib plot or an image inside a notebook cell. It sounds simple until you realize the rendering pipeline doesn't always cooperate the way you expect, and your text either gets clipped, appears behind the chart elements, or shows up at the wrong scale. The two main paths are matplotlib's annotation system and IPython.display for HTML-based overlays. Matplotlib handles the standard use case. IPython.display handles the "I need precise positioning and CSS control" use case. When you draw on a figure, matplotlib pushes artist objects into layers. Text defaults to an order that sometimes places it below patches, lines, and scatter points. The fix is almost always zorder. Setting a higher zorder on your text or annotation pushes it forward.
I spent about four hours debugging this last winter on a project where I was overlaying regional labels on a choropleth map. The text kept rendering behind the filled polygons because the polygon patches had an implicit zorder higher than the default text layer. The workaround was adding zorder=5 to every ax.annotate() call and zorder=4 to the label markers. That's the counter-intuitive part most tutorials skip: patches often win the layering battle by default, so you have to manually pull text above them. Here's the pattern that works reliably: ax.text(x, y, "label", zorder=10, color="white", fontsize=11, bbox=dict(facecolor="none", edgecolor="none"))
The bbox with facecolor="none" matters because without it, text boxes can sometimes introduce an unexpected layer that renders behind line collections. Empty bounding boxes don't create their own layering entry in the artists list.
Get the Full Details

IPython.display for HTML Overlays
When the plot already exists and you need text positioned pixel-precisely over it, the matplotlib approach gets fiddly. The HTML overlay method is cleaner for static compositions. You render the image or plot to a buffer, convert it to base64, and place it inside an HTML container with a position:relative wrapper. Then text sits in absolutely positioned divs on top. This is how most dashboard tools handle it because it avoids the coordinate system translation problems that come with matplotlib annotations. I use this method when exporting notebooks as static reports. The matplotlib approach introduces rendering artifacts sometimes depending on the backend. The HTML overlay is consistent across output formats.
The basic structure: from IPython.display import display, HTML image_html = '
display(HTML(image_html))

A Realistic Edge Case I Keep Running Into
Jupyter's inline backend sometimes caches the figure object before you've added your annotations, especially if you're building figures incrementally across multiple cells. I hit this yesterday on a live notebook when the text overlays appeared in one cell but not in the exported static HTML. The issue was that the figure had already been rendered in a previous cell's execution, and subsequent annotation calls were updating the data but not triggering a re-render cycle. The fix is forcing a redraw after adding annotations: plt.gcf().canvas.draw(). It's ugly but necessary when working in multi-cell incremental workflows. Without it, you can end up with a figure that looks correct in memory but exports stale content.
Common Pitfalls
First, the transform parameter. Default text transforms use data coordinates. When your axes have unequal aspect ratios or log scales, text placement drifts from where it visually appears. Use ax.transData explicitly or switch to axes fraction coordinates (transform=ax.transAxes) when positioning relative to the plot frame rather than data space. Second, font rendering. Matplotlib's default backends occasionally cut off text at tight axis bounds. If your annotation sits near the edge, it disappears. You'll need to expand the margins slightly or use clip_on=False to allow text to bleed past the axes boundary. Third, the HTML overlay method only works for static output. If you need interactive text that responds to zoom or pan, you're better off with mpld3 or plotly. The HTML approach locks everything to pixel coordinates and ignores any axis transformation changes.
When This Approach Fails
Writing text over visual elements breaks down when the background is complex. A choropleth with many colors makes white text illegible in half the regions. Black text fails similarly on dark basemaps. The practical solution is either adding a semi-transparent background to the text bbox or switching to an outline-only text style with a contrasting stroke. Neither is particularly elegant but both are functional. For anything beyond simple annotations, consider building the composition outside the notebook and embedding the final image. The HTML overlay path works for quick dashboards, but when precision matters for publication-quality output, dedicated tools like Inkscape or even a small Python script using PIL for final compositing give you control that neither matplotlib nor notebook HTML can match reliably.
