2025, Dec 05 01:00

How to Fix Misaligned Labels in Matplotlib 3D Bar Charts with a Consistent Z-Offset

Learn a reliable way to align value labels under bars in Matplotlib 3D bar charts. Use a small z-offset and consistent anchors to fix Bar3D label drift.

Label placement on 3D charts tends to be fickle: a few text annotations sit nicely aligned under their bars, while others drift inside the columns or float away. When you need every value to appear just below the corresponding bar and visually track along the same diagonal, tiny miscalculations in position and alignment become very noticeable. This guide shows a minimal, targeted adjustment to get consistent placement in a Matplotlib bar3d setup without changing the chart’s underlying logic.

Problem setup

The example below reproduces the scenario where only one label appears correctly aligned under its bar, while others end up inside or at varying distances. The code builds a stacked 3D bar chart in Matplotlib and draws per-bar value labels.

import matplotlib.pyplot as plt
from matplotlib.lines import Line2D
import numpy as np
import pandas as pd

palette_map = {'used': '#A54836', 'free': '#5375D4'}

axis_tick_color, xlab_tone, totals_tone, legend_tone, grid_tone, label_tone = '#101628', "#101628", "#101628", "#101628", "#C8C9C9", "#757C85"

frame = pd.DataFrame([
    ['hd_xxxxxx-ppp-d2', 355, 237, 592],
    ['hd_disk_vvvvvvvv', 0, 184, 184],
    ['hd_aaaa-C_DDDDDDDDDDD', 0, 240, 240],
    ['hd_AAAAAAA_DDDDDDDDDDD', 1, 9, 10],
    ['hd_xxxxxx-ppp-d1', 870, 342, 1212],
    ['hd_aaaa_CC2-SSSS-01', 0, 9, 9],
    ['hd_aaaa_DD1-MMMM-01', 387, 286, 673],
    ['hd_wwww_DD2-MMMM-01', 437, 230, 667],
    ['hd_disk_bbbbbbbbbb', 0, 179, 179],
])
frame.columns = ['disk_pool_name', 'used_space_kb', 'free_space_kb', 'total_capacity']

used_blk = frame[['disk_pool_name', 'used_space_kb']].rename(columns={"used_space_kb": "TB"})
used_blk['status'] = 'used'
free_blk = frame[['disk_pool_name', 'free_space_kb']].rename(columns={"free_space_kb": "TB"})
free_blk['status'] = 'free'

stacked_df = pd.concat([used_blk, free_blk], axis=0, ignore_index=True, sort=False)
stacked_df = stacked_df.sort_values(['disk_pool_name', 'status'], ascending=[True, False])

stacked_df['colors'] = stacked_df.status.map(palette_map)

pools = stacked_df.disk_pool_name.unique()
states = ['used', 'free']
n_states = len(states)

caps_total = frame.sort_values(['disk_pool_name'])['total_capacity']
bar_colors = stacked_df.colors

width_x = 100
width_y = 100

heights = stacked_df.TB.to_list()

step_x = 250
step_y = 125

xpos = np.repeat(np.arange(0, len(pools)) * step_x, len(states)).tolist()
ypos = np.repeat(np.arange(0, len(pools)) * step_y, len(states)).tolist()

base_z = np.array(stacked_df.groupby('disk_pool_name', sort=False).TB.apply(list).tolist())
base_z = np.cumsum(base_z, axis=1)[:, :n_states-1]
base_z = np.insert(base_z, 0, 0, axis=1).flatten().tolist()

canvas = plt.figure(figsize=(10, 8))
axes3d = canvas.add_subplot(1, 1, 1, projection='3d')

axes3d.bar3d(xpos, ypos, base_z, width_x, width_y, heights, color=bar_colors, label=states)

for xb, yb, zb, h in zip(xpos, ypos, base_z, heights):
    value = h if h > 0 else ''
    axes3d.text(xb - 1.5, yb, zb + h / 2, value, size=10, ha="right", color=label_tone)

legend_handles = [Line2D([0], [0], color=c, marker='s', linestyle='', markersize=10,) for c in reversed(bar_colors.unique())]
legend_labels = stacked_df.status.unique()
plt.legend(legend_handles, reversed(legend_labels), labelcolor=legend_tone,
           prop=dict(weight='bold', size=12),
           bbox_to_anchor=(0.5, -0.05), loc="lower center",
           ncols=2, frameon=False, fontsize=14)

for i, (pool, total_cap) in enumerate(zip(pools, caps_total)):
    axes3d.text(xpos[i * n_states] - 100, ypos[i * n_states] - 710, z=-200, zdir='y',
                s=f"{pool}", color=axis_tick_color, weight="bold", fontsize=7)

    axes3d.text(xpos[i * n_states] - 130, ypos[i * n_states] + 260, z=base_z[i * n_states] + total_cap + 2,
                s=f"{total_cap}", fontsize=10, weight="bold", color=totals_tone,
                bbox=dict(facecolor='none', edgecolor='#EBEDEE', boxstyle='round,pad=0.3'))

axes3d.set_aspect("equal")

What actually causes the misalignment

The labels are placed using the bar base, the bar height, and text alignment settings. When those three don’t cooperate, the text easily ends up too high, too low, or shifted to the side. Tweaking zdir alone won’t solve this for every bar because the perceived “diagonal” alignment in a 3D view comes from the camera perspective and consistent offsets in data coordinates, not from rotating the text itself. The key is to anchor labels consistently relative to each bar’s position and nudge them a small, uniform distance along the z axis.

Solution: use a small z-offset and consistent alignment

Position each label just below the bar by applying a slight negative offset in z, and align it centrally above that anchor. This stabilizes the spacing visually across all bars and keeps the labels close and consistently “diagonal” relative to the perspective.

axes3d.text(x, y, z - 0.05,  # adjust this small offset as needed
            label_point,
            size=10,
            ha='center',
            va='top',
            zdir='z',
            color=label_tone)

Here is the full chart code with the adjusted label placement integrated. Everything else remains functionally identical.

import matplotlib.pyplot as plt
from matplotlib.lines import Line2D
import numpy as np
import pandas as pd

palette_map = {'used': '#A54836', 'free': '#5375D4'}

axis_tick_color, xlab_tone, totals_tone, legend_tone, grid_tone, label_tone = '#101628', "#101628", "#101628", "#101628", "#C8C9C9", "#757C85"

frame = pd.DataFrame([
    ['hd_xxxxxx-ppp-d2', 355, 237, 592],
    ['hd_disk_vvvvvvvv', 0, 184, 184],
    ['hd_aaaa-C_DDDDDDDDDDD', 0, 240, 240],
    ['hd_AAAAAAA_DDDDDDDDDDD', 1, 9, 10],
    ['hd_xxxxxx-ppp-d1', 870, 342, 1212],
    ['hd_aaaa_CC2-SSSS-01', 0, 9, 9],
    ['hd_aaaa_DD1-MMMM-01', 387, 286, 673],
    ['hd_wwww_DD2-MMMM-01', 437, 230, 667],
    ['hd_disk_bbbbbbbbbb', 0, 179, 179],
])
frame.columns = ['disk_pool_name', 'used_space_kb', 'free_space_kb', 'total_capacity']

used_blk = frame[['disk_pool_name', 'used_space_kb']].rename(columns={"used_space_kb": "TB"})
used_blk['status'] = 'used'
free_blk = frame[['disk_pool_name', 'free_space_kb']].rename(columns={"free_space_kb": "TB"})
free_blk['status'] = 'free'

stacked_df = pd.concat([used_blk, free_blk], axis=0, ignore_index=True, sort=False)
stacked_df = stacked_df.sort_values(['disk_pool_name', 'status'], ascending=[True, False])

stacked_df['colors'] = stacked_df.status.map(palette_map)

pools = stacked_df.disk_pool_name.unique()
states = ['used', 'free']
n_states = len(states)

caps_total = frame.sort_values(['disk_pool_name'])['total_capacity']
bar_colors = stacked_df.colors

width_x = 100
width_y = 100

heights = stacked_df.TB.to_list()

step_x = 250
step_y = 125

xpos = np.repeat(np.arange(0, len(pools)) * step_x, len(states)).tolist()
ypos = np.repeat(np.arange(0, len(pools)) * step_y, len(states)).tolist()

base_z = np.array(stacked_df.groupby('disk_pool_name', sort=False).TB.apply(list).tolist())
base_z = np.cumsum(base_z, axis=1)[:, :n_states-1]
base_z = np.insert(base_z, 0, 0, axis=1).flatten().tolist()

canvas = plt.figure(figsize=(10, 8))
axes3d = canvas.add_subplot(1, 1, 1, projection='3d')

axes3d.bar3d(xpos, ypos, base_z, width_x, width_y, heights, color=bar_colors, label=states)

for xb, yb, zb, h in zip(xpos, ypos, base_z, heights):
    value = h if h > 0 else ''
    axes3d.text(xb, yb, zb - 0.05,
                value,
                size=10,
                ha='center',
                va='top',
                zdir='z',
                color=label_tone)

legend_handles = [Line2D([0], [0], color=c, marker='s', linestyle='', markersize=10,) for c in reversed(bar_colors.unique())]
legend_labels = stacked_df.status.unique()
plt.legend(legend_handles, reversed(legend_labels), labelcolor=legend_tone,
           prop=dict(weight='bold', size=12),
           bbox_to_anchor=(0.5, -0.05), loc="lower center",
           ncols=2, frameon=False, fontsize=14)

for i, (pool, total_cap) in enumerate(zip(pools, caps_total)):
    axes3d.text(xpos[i * n_states] - 100, ypos[i * n_states] - 710, z=-200, zdir='y',
                s=f"{pool}", color=axis_tick_color, weight="bold", fontsize=7)

    axes3d.text(xpos[i * n_states] - 130, ypos[i * n_states] + 260, z=base_z[i * n_states] + total_cap + 2,
                s=f"{total_cap}", fontsize=10, weight="bold", color=totals_tone,
                bbox=dict(facecolor='none', edgecolor='#EBEDEE', boxstyle='round,pad=0.3'))

axes3d.set_aspect("equal")

Why this matters

Reliable label placement is not a cosmetic detail. It directly affects comprehension and the speed at which readers can parse quantitative values. With a small, consistent z offset and alignment, the labels land right under the bars in a predictable, legible way, which is essential when you want the numbers to be understood at a glance.

My apologies, I know you certainly did not ask for this advice, but please reconsider using a 3D bar chart. It's well known that it is impossible to read them accurately. Looking at your plot, there's not that much data — it would almost certainly be better to make a table for it.

Takeaways

If your 3D bar labels look inconsistent, don’t wrestle with zdir alone. Anchor each text to the bar’s base and apply a small negative z offset, combined with central horizontal alignment and a top vertical anchor. The change is minimal, but it stabilizes the visual relationship between the labels and the bars, making the chart far easier to read.