2025, Sep 28 07:00
Align Logarithmic Heatmap Colors by Powers of Ten with LogNorm vmin and vmax in Seaborn/Matplotlib
Learn to fix shifted logarithmic heatmap colors in Seaborn/Matplotlib by setting LogNorm vmin and vmax for one color per decade, from 10^0 to 10^9. Set bounds.
When plotting a heatmap on a logarithmic scale, you often want each color to represent a single order of magnitude: 10^0, 10^1, 10^2, and so on. If the color mapping looks shifted or compressed, even though your colormap has the right number of discrete colors, the culprit is usually how normalization infers the data range.
Problem setup
The data in question spans roughly from 10^0 up to well below 10^9, but the goal is to use a discrete logarithmic color scale where each color stands for one order of magnitude. Here is a minimal example of the plotting code that produces an unexpected mapping:
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
from matplotlib.colors import LogNorm
df_src = pd.read_excel('example_data.xlsx')
palette_rocket = sns.color_palette("rocket", as_cmap=False, n_colors=9)
canvas, axes = plt.subplots(figsize=(20, 10))
axes = sns.heatmap(
    df_src,
    norm=LogNorm(),
    annot=True,
    cmap=palette_rocket,
    linewidths=0.05,
    linecolor='grey'
)
plt.tight_layout()
plt.show()The lower bound of the colorbar starts at 10^0 as intended, but subsequent colors don’t align to 10^1, 10^2, etc. In the annotations, values like ~6.3×10^5 and ~9.2×10^5 may share a color with 1×10^6, while 5×10^6 suddenly flips to another color, even though they are in the same order of magnitude.
Why the mapping looks off
The normalization is data-driven by default. When you use a logarithmic normalizer without explicitly setting bounds, it derives its range from the data’s minimum and maximum. In other words, it behaves as if it were called with vmin=data.min() and vmax=data.max(). If your dataset doesn’t actually reach 10^9, the log scale is fitted to that smaller span, and the available colors are distributed across that inferred range. That’s why the boundaries between colors won’t align with the clean powers of ten you expect.
Nothing in the plotting code indicates that the color scale should run from exactly 10^0 to 10^9. All that’s specified is “use 9 colors on a log scale.” The normalizer can’t guess the intended domain if the data doesn’t cover it.
Solution: fix the normalization domain
If you want each color to map to a specific order of magnitude, instruct the normalizer to “pretend” the data spans the desired domain. Set explicit bounds on the logarithmic normalizer. Since the normalizer is responsible for mapping data into the [0, 1] interval, pass vmin and vmax to LogNorm, not to the heatmap call itself.
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
from matplotlib.colors import LogNorm
df_src = pd.read_excel('example_data.xlsx')
palette_rocket = sns.color_palette("rocket", as_cmap=False, n_colors=9)
canvas, axes = plt.subplots(figsize=(20, 10))
axes = sns.heatmap(
    df_src,
    norm=LogNorm(vmin=1, vmax=1e9),
    annot=True,
    cmap=palette_rocket,
    linewidths=0.05,
    linecolor='grey'
)
plt.tight_layout()
plt.show()This enforces a discrete, order-of-magnitude mapping from 10^0 to 10^9, regardless of whether the data actually reaches the upper end. If you instead set vmin=data.min() and vmax=data.max(), you’d just be reproducing the default, data-driven behavior.
Why this matters
Color encodes meaning. If you want a color to signify “this value falls into a specific decade on a log scale,” you need a stable domain that doesn’t quietly shift with the dataset’s current min and max. Otherwise, two plots using the same colormap can imply different thresholds, making comparisons unreliable.
Takeaways
If your goal is one color per order of magnitude, don’t rely on implicit normalization. Make the intent explicit by setting the logarithmic domain with LogNorm(vmin=10^a, vmax=10^b). This avoids unexpected color boundaries and keeps the mapping aligned with powers of ten. And remember, passing bounds to the normalizer is the key step; doing so on the heatmap call won’t override a custom norm that already controls the mapping.