You sometimes come across heat maps in data visualization, and they’re used to represent data values with color gradients. This technique is great for identifying patterns, trends, outliers, and missing data when there’s lots of data. Tables can have this sort of treatment as well! Typically, formatted numeric values are shown along with some color treatment coinciding with the underlying data values.
Data coloring works best when you want readers to spot patterns across many values at once (e.g., trends over time, outliers, relative magnitude, etc.). For tables with just a few rows, explicit formatting or footnotes may be more effective since the reader can take in all the values directly. But when a table has dozens or hundreds of cells, color provides a visual shortcut that no amount of bold or italics can match.
We can make this possible in Great Tables by using the data_color() method. Let’s start with a simple example, using a Polars DataFrame with three columns of values. We can introduce that data to GT and use data_color() without any arguments.
from great_tables import GT
import polars as pl
simple_df = pl.DataFrame(
{
"integer": [1, 2, 3, 4, 5],
"float": [2.3, 1.3, 5.1, None, 4.4],
"category": ["one", "two", "three", "one", "three"],
}
)
GT(simple_df).data_color()
| integer |
float |
category |
| 1 |
2.3 |
one |
| 2 |
1.3 |
two |
| 3 |
5.1 |
three |
| 4 |
None |
one |
| 5 |
4.4 |
three |
This works but doesn’t look all too appealing. However, we can take note of a few things straight away. The first thing is that data_color() doesn’t format the values but rather it applies color fill values to the cells. The second thing is that you don’t have to intervene and modify the text color so that there’s enough contrast, Great Tables will do that for you (this behavior can be deactivated with the autocolor_text= argument though).
Setting palette colors
While this first example illustrated some basic things, the common thing to do in practices to provide a list of colors to the palette= argument. Let’s choose two colors "green" and "red" and place them in that order.
GT(simple_df).data_color(palette=["blue", "red"])
| integer |
float |
category |
| 1 |
2.3 |
one |
| 2 |
1.3 |
two |
| 3 |
5.1 |
three |
| 4 |
None |
one |
| 5 |
4.4 |
three |
Now that we’ve moved away from the default palette and specified colors, we can see that lower numerical values are closer to blue and higher values are closer to red (those in the middle have colors that are a blend of the two and, in this case, more in the purple range). Categorical values behave similarly, they take on ordinal values based on their first appearance (from top to bottom) and those values are used to generate the background colors.
When choosing palette colors, consider the nature of your data. For sequential data (low to high), use a single-hue gradient or a well-known sequential palette. For diverging data (values above and below a midpoint), use a diverging palette with a neutral middle color. Avoid using red-green combinations, which are difficult for colorblind readers (blue-orange or purple-green are safer alternatives).
Coloring missing values with na_color
There is a lone "None" value in the float column, and it has a gray background. Throughout the Great Tables package, missing values are treated in different ways and, in this case, it’s given a default color value. We can change that with the na_color= argument. Let’s try it now:
GT(simple_df).data_color(palette=["blue", "red"], na_color="#FFE4C4")
| integer |
float |
category |
| 1 |
2.3 |
one |
| 2 |
1.3 |
two |
| 3 |
5.1 |
three |
| 4 |
None |
one |
| 5 |
4.4 |
three |
Now, the gray color has been changed to Bisque. Note that when it comes to colors, you can use any combination of CSS/X11 color names and hexadecimal color codes.
Using domain= to color values across columns
The previous usages of the data_color() method were such that the color ranges encompassed the boundaries of the data values. That can be changed with the domain= argument, which expects a list of two values (a lower and an upper value). Let’s use the range [0, 10] on the first two columns, integer and float, and not the third (since a numerical domain is incompatible with string-based values). Here’s the table code for that:
(
GT(simple_df)
.data_color(
columns=["integer", "float"],
palette=["blue", "red"],
domain=[0, 10],
na_color="white"
)
)
| integer |
float |
category |
| 1 |
2.3 |
one |
| 2 |
1.3 |
two |
| 3 |
5.1 |
three |
| 4 |
None |
one |
| 5 |
4.4 |
three |
Nice! We can clearly see that the color ramp in the first column (integer) only proceeds from blue (value: 1) to purple (value: 5) and there isn’t a reddish color in sight (would need a value close to 10).
Setting an explicit domain matters because without one, each column scales independently (the same color can represent different values in different columns). By setting a shared domain, you ensure that colors are comparable across columns, which is critical when columns measure the same quantity. In this example, both integer and float use the [0, 10] range, so a given shade of purple means the same thing regardless of which column it appears in.
Centering colors with midpoint=
Some values have a natural center: changes are centered on zero, ratios on one, and scores on some target. For these, a diverging palette (two colors with a neutral one in between) works well, but only if the neutral color lands on that center. By default it doesn’t: the middle color of the palette goes to the middle of the domain, wherever that happens to be. The midpoint= argument pins the center of the palette to a value of your choosing.
Here’s a table of changes, some negative and some positive. With midpoint=0, decreases become increasingly red and increases become increasingly green, the further they are from zero:
changes_df = pl.DataFrame(
{
"region": ["North", "South", "East", "West", "Central"],
"change": [12.5, -8.1, 0.0, 3.2, -1.0],
}
)
red_white_green = ["#D7191C", "white", "#1A9641"]
(
GT(changes_df)
.data_color(columns="change", palette=red_white_green, midpoint=0)
.fmt_number(columns="change", decimals=1, force_sign=True)
)
| region |
change |
| North |
+12.5 |
| South |
−8.1 |
| East |
0.0 |
| West |
+3.2 |
| Central |
−1.0 |
Notice that the largest decrease (-8.1) is a less intense red than the largest increase (12.5) is green. Without a domain=, the domain is made symmetric around the midpoint (here, it becomes [-12.5, 12.5]), so the strength of a color reflects how far a value is from the midpoint, the same on both sides. A change of -12.5 would get the full red.
If you supply a domain= as well, then each side of the midpoint is scaled separately, from the midpoint out to its end of the domain. This is how three-color scales work in spreadsheet software. It’s useful when the two sides cover ranges of different sizes. Here, sales are shown as a fraction of a target, so the midpoint is 1. The colors go from red to white over the wide range from 50% of the target up to the target, and from white to green over the narrower range from the target up to 120% of it:
sales_df = pl.DataFrame(
{
"rep": ["Ana", "Ben", "Cai", "Dee", "Eli"],
"pct_of_target": [0.62, 0.88, 1.0, 1.08, 1.17],
}
)
(
GT(sales_df)
.data_color(
columns="pct_of_target",
palette=red_white_green,
domain=[0.5, 1.2],
midpoint=1,
)
.fmt_percent(columns="pct_of_target", decimals=0)
)
| rep |
pct_of_target |
| Ana |
62% |
| Ben |
88% |
| Cai |
100% |
| Dee |
108% |
| Eli |
117% |
Both sides now use their full range of colors. 1.17 is only 0.17 above the target, but it’s about as strongly green as 0.62 (more than twice as far below the target) is red. Keep in mind that this makes equal distances from the midpoint look different on each side, so the symmetric behavior is usually the more honest choice when the two sides measure the same kind of change.
Choosing a contrast algorithm with contrast_algo=
We noted earlier that data_color() automatically picks a light or dark text color for each cell so that values remain legible against their background fill. How that choice gets made depends on the contrast algorithm in use, and that’s settable with the contrast_algo= argument. There are two options:
"apca" (the default): the Accessible Perceptual Contrast Algorithm, which models how people actually perceive the lightness contrast of text against a background
"wcag": the contrast ratio formula from the Web Content Accessibility Guidelines (WCAG 2.x)
The two algorithms agree on very light and very dark backgrounds but they can differ on mid-tone colors. The WCAG formula is known to overstate the contrast of dark text on saturated, medium-dark backgrounds (e.g., reds and blues), so it often selects black text where white text is easier to read. APCA does a better job with these colors. To see the difference, let’s color the same set of values twice, once with each algorithm:
contrast_df = pl.DataFrame(
{
"apca": [1, 2, 3, 4, 5, 6, 7],
"wcag": [1, 2, 3, 4, 5, 6, 7],
}
)
palette = ["#2C7BB6", "#ABD9E9", "#FDAE61", "#D7191C"]
(
GT(contrast_df)
.data_color(columns="apca", palette=palette, contrast_algo="apca")
.data_color(columns="wcag", palette=palette, contrast_algo="wcag")
)
| apca |
wcag |
| 1 |
1 |
| 2 |
2 |
| 3 |
3 |
| 4 |
4 |
| 5 |
5 |
| 6 |
6 |
| 7 |
7 |
Both columns have identical background colors, but the blue and red-orange cells get white text under APCA and black text under WCAG. The APCA choices are noticeably more legible for those cells. If you need to match the WCAG 2.x guidelines for compliance reasons, then use contrast_algo="wcag". Otherwise, the default of "apca" is the better choice for readability.
Custom color mapping with fn=
A palette spread across a domain covers many needs, but sometimes the rules for coloring are more specific: a color for every value above a threshold, different colors for positive and negative values, or a fixed color for each category. For these cases, data_color() accepts a color-mapping function through its fn= argument.
The function is called once for each targeted column. It receives a list of that column’s values and must return a list of colors of the same length. Here’s a function that colors values in the float column according to whether they’re below or above 3:
def above_below_3(vals):
return [None if x is None else "lightblue" if x < 3 else "orange" for x in vals]
GT(simple_df).data_color(columns="float", fn=above_below_3, na_color="lightgray")
| integer |
float |
category |
| 1 |
2.3 |
one |
| 2 |
1.3 |
two |
| 3 |
5.1 |
three |
| 4 |
None |
one |
| 5 |
4.4 |
three |
There are a few things to know about how this works:
- Missing values are passed to the function. In a Polars DataFrame they arrive as
None (with pandas, they usually arrive as NaN, and pd.isna() is a reliable way to check for missing values from either library). The function needs to handle them, or a comparison like x < 3 will fail.
- Returning
None gives the na_color= color. That’s what happened to the missing value above. It’s also a handy way to leave out any value you’d rather not color.
- Some arguments step aside. The
palette=, domain=, reverse=, truncate=, and midpoint= arguments are ignored when fn= is used, since the function takes over their job. The na_color=, alpha=, and autocolor_text= arguments still apply, so text is automatically recolored for contrast as usual.
- Any column type works. The function decides what the values mean, so you can color columns that the built-in mapping can’t handle, such as booleans.
Here’s that last point in action, with a column of booleans and a translucent fill (through alpha=):
tasks_df = pl.DataFrame(
{
"task": ["Build", "Test", "Docs", "Release"],
"done": [True, True, False, None],
}
)
GT(tasks_df).data_color(
columns="done",
fn=lambda vals: [None if v is None else "seagreen" if v else "firebrick" for v in vals],
na_color="#FFF3B0",
alpha=0.6,
)
| task |
done |
| Build |
True |
| Test |
True |
| Docs |
False |
| Release |
None |
A common use of a custom function is to call out the sign of values, perhaps in the table of changes from earlier:
def sign_colors(vals):
return [
None if x is None else "#1B9E77" if x > 0 else "#D95F02" if x < 0 else "#FFFFFF"
for x in vals
]
(
GT(changes_df)
.data_color(columns="change", fn=sign_colors)
.fmt_number(columns="change", decimals=1, force_sign=True)
)
| region |
change |
| North |
+12.5 |
| South |
−8.1 |
| East |
0.0 |
| West |
+3.2 |
| Central |
−1.0 |
Color-mapping helpers
Writing a function by hand is fine for a few fixed colors, but interpolating along a gradient or cutting values into bins takes more work. Great Tables provides three helpers that create color-mapping functions for you, ready to be passed to fn=:
- col_numeric() maps numeric values to a continuous gradient
- col_bin() cuts numeric values into bins and gives each bin a color
- col_factor() gives each category its own color
If you’ve used gt in R, these will be familiar: they’re modeled on the col_numeric(), col_bin(), and col_factor() functions from the scales package. Each one takes a palette=, which can be a list of colors or the name of a ColorBrewer or viridis palette (e.g., "Blues" or "viridis"), just as with data_color().
The col_numeric() helper interpolates along a palette, much like data_color() does with a numeric column, and it has the same domain=, midpoint=, and truncate= arguments. Here’s a diverging scale with a domain centered on zero:
from great_tables import col_numeric
(
GT(changes_df)
.data_color(columns="change", fn=col_numeric(palette=red_white_green, domain=[-15, 15]))
.fmt_number(columns="change", decimals=1, force_sign=True)
)
| region |
change |
| North |
+12.5 |
| South |
−8.1 |
| East |
0.0 |
| West |
+3.2 |
| Central |
−1.0 |
Values outside of the domain are treated as missing by default and receive the na_color= color. If you’d rather they took on the colors at the ends of the palette, use truncate=True. Here, the domain is narrowed to [-5, 5], so both 12.5 and -8.1 are fully saturated:
(
GT(changes_df)
.data_color(
columns="change",
fn=col_numeric(palette=red_white_green, domain=[-5, 5], truncate=True),
)
.fmt_number(columns="change", decimals=1, force_sign=True)
)
| region |
change |
| North |
+12.5 |
| South |
−8.1 |
| East |
0.0 |
| West |
+3.2 |
| Central |
−1.0 |
If domain= isn’t given, then the domain is taken from the range of values in each column. That’s often useful, but for a diverging scale it would put the middle color at the middle of the data rather than at zero. As with data_color(), the fix is midpoint=0.
Pinning colors to values with stops=
What col_numeric() adds is the stops= argument, which pins each color in the palette to a value. It takes one stop per color, and colors are interpolated between neighboring stops. A stop can be a data value (a number) or a position within the range of values in the column (a percentage string, from "0%" to "100%"). Mixing the two is where this gets useful. Here, the lowest value is fully red, zero is white, and the highest value is fully green:
(
GT(changes_df)
.data_color(
columns="change",
fn=col_numeric(palette=red_white_green, stops=["0%", 0, "100%"]),
)
.fmt_number(columns="change", decimals=1, force_sign=True)
)
| region |
change |
| North |
+12.5 |
| South |
−8.1 |
| East |
0.0 |
| West |
+3.2 |
| Central |
−1.0 |
Because the percentages are worked out separately for each column, the same function can be used on columns with very different ranges, and zero will be white in all of them. (The range that the percentages refer to also stretches to include any numeric stops, so a column with no negative values is colored from white up to green, with no red at all.)
Repeating a stop makes a sharp change in color at that value, rather than a gradual one. Here, decreases are shades of orange and increases are shades of purple, with no blending across zero:
(
GT(changes_df)
.data_color(
columns="change",
fn=col_numeric(
palette=["#E66101", "#FDB863", "#B2ABD2", "#5E3C99"],
stops=["0%", 0, 0, "100%"],
),
)
.fmt_number(columns="change", decimals=1, force_sign=True)
)
| region |
change |
| North |
+12.5 |
| South |
−8.1 |
| East |
0.0 |
| West |
+3.2 |
| Central |
−1.0 |
A value that sits exactly on a repeated stop takes the later color, so the 0.0 change is light purple. Stops beyond the range of the values work too: the outermost stops act as the domain, so values outside of them are treated as missing unless truncate=True is used.
Sometimes it’s clearer to group values into a few ranges than to show a smooth gradient. The col_bin() helper does this. Its bins= argument is either a number of equal-width bins, or a list of bin boundaries:
from great_tables import col_bin
scores_df = pl.DataFrame(
{
"student": ["Ana", "Ben", "Cai", "Dee", "Eli", "Fay"],
"score": [38, 45, 62, 78, 95, 100],
}
)
GT(scores_df).data_color(columns="score", fn=col_bin(palette="YlGn", bins=[0, 50, 70, 90, 100]))
| student |
score |
| Ana |
38 |
| Ben |
45 |
| Cai |
62 |
| Dee |
78 |
| Eli |
95 |
| Fay |
100 |
By default, each bin includes its lower boundary but not its upper one, so a score of exactly 70 falls in the [70, 90) bin. The last bin includes both of its boundaries, so the perfect score of 100 is still colored. Use right=True to flip this, so that bins include their upper boundaries instead. And as with col_numeric(), values outside of the outermost boundaries are treated as missing unless truncate=True is used.
The col_factor() helper gives each distinct value its own color. On its own that’s similar to what data_color() does with a string column, but col_factor() lets you fix the set of levels and their order with domain=. This means a level always receives the same color, no matter which levels are present in the column or in what order they appear. That’s important when building several tables that should be colored consistently. Any value not in the domain receives the missing-value color, which we can use here to flag an unexpected value:
from great_tables import col_factor
tickets_df = pl.DataFrame(
{
"ticket": [101, 102, 103, 104, 105],
"priority": ["low", "high", "medium", "high", "urgent"],
}
)
GT(tickets_df).data_color(
columns="priority",
fn=col_factor(palette=["#FEF0D9", "#FDCC8A", "#FC8D59"], domain=["low", "medium", "high"]),
na_color="#D7301F",
)
| ticket |
priority |
| 101 |
low |
| 102 |
high |
| 103 |
medium |
| 104 |
high |
| 105 |
urgent |
If the palette has fewer colors than there are levels, then the in-between colors are interpolated along the palette.
Combining helpers with your own logic
The helpers return ordinary functions, so they can be called from inside a function of your own. This is handy when you want to adjust some of the colors a helper gives. For example, to draw attention only to the larger changes, we can color the values with col_numeric() and then replace the colors of any small changes (less than 2 in either direction) with None, so that they get the plain na_color= color:
color_change = col_numeric(palette=red_white_green, midpoint=0)
def large_changes_only(vals):
colors = color_change(vals)
return [None if x is None or abs(x) < 2 else color for x, color in zip(vals, colors)]
(
GT(changes_df)
.data_color(columns="change", fn=large_changes_only, na_color="white")
.fmt_number(columns="change", decimals=1, force_sign=True)
)
| region |
change |
| North |
+12.5 |
| South |
−8.1 |
| East |
0.0 |
| West |
+3.2 |
| Central |
−1.0 |
The -1.0 and 0.0 changes are left white, while the others keep the colors they’d have had on the full scale.
Bringing it all together
For a more advanced treatment of data colorization in the table, let’s take the sza dataset (available in the great_tables.data submodule) and vigorously reshape it with Polars so that solar zenith angles are arranged as rows by month, and the half-hourly clock times are the columns (from early morning to solar noon).
Once the pivot()ing is done, we can introduce that that table to the GT class, placing the names of the months in the table stub. We will use data_color() with a domain that runs from 90 to 0 (here, 90° is sunrise, and 0° is represents the sun angle that’s directly overhead). There are months where the sun rises later in the morning, before the sunrise times we’ll see missing values in the dataset, and na_color="white" will handle those cases. Okay, that’s the plan, and now here’s the code:
from great_tables import html
from great_tables.data import sza
import polars.selectors as cs
sza_pivot = (
pl.from_pandas(sza)
.filter((pl.col("latitude") == "20") & (pl.col("tst") <= "1200"))
.select(pl.col("*").exclude("latitude"))
.drop_nulls()
.pivot(values="sza", index="month", on="tst", sort_columns=True)
)
(
GT(sza_pivot, rowname_col="month")
.data_color(
domain=[90, 0],
palette=["rebeccapurple", "white", "orange"],
na_color="white",
)
.tab_header(
title="Solar Zenith Angles from 05:30 to 12:00",
subtitle=html("Average monthly values at latitude of 20°N."),
)
)
| Solar Zenith Angles from 05:30 to 12:00 |
| Average monthly values at latitude of 20°N. |
|
0530 |
0600 |
0630 |
0700 |
0730 |
0800 |
0830 |
0900 |
0930 |
1000 |
1030 |
1100 |
1130 |
1200 |
| jan |
None |
None |
None |
84.9 |
78.7 |
72.7 |
66.1 |
61.5 |
56.5 |
52.1 |
48.3 |
45.5 |
43.6 |
43.0 |
| feb |
None |
None |
88.9 |
82.5 |
75.8 |
69.6 |
63.3 |
57.7 |
52.2 |
47.4 |
43.1 |
40.0 |
37.8 |
37.2 |
| mar |
None |
None |
85.7 |
78.8 |
72.0 |
65.2 |
58.6 |
52.3 |
46.2 |
40.5 |
35.5 |
31.4 |
28.6 |
27.7 |
| apr |
None |
88.5 |
81.5 |
74.4 |
67.4 |
60.3 |
53.4 |
46.5 |
39.7 |
33.2 |
26.9 |
21.3 |
17.2 |
15.5 |
| may |
None |
85.0 |
78.2 |
71.2 |
64.3 |
57.2 |
50.2 |
43.2 |
36.1 |
29.1 |
26.1 |
15.2 |
8.8 |
5.0 |
| jun |
89.2 |
82.7 |
76.0 |
69.3 |
62.5 |
55.7 |
48.8 |
41.9 |
35.0 |
28.1 |
21.1 |
14.2 |
7.3 |
2.0 |
| jul |
88.8 |
82.3 |
75.7 |
69.1 |
62.3 |
55.5 |
48.7 |
41.8 |
35.0 |
28.1 |
21.2 |
14.3 |
7.7 |
3.1 |
| aug |
None |
83.8 |
77.1 |
70.2 |
63.3 |
56.4 |
49.4 |
42.4 |
35.4 |
28.3 |
21.3 |
14.3 |
7.3 |
1.9 |
| sep |
None |
87.2 |
80.2 |
73.2 |
66.1 |
59.1 |
52.1 |
45.1 |
38.1 |
31.3 |
24.7 |
18.6 |
13.7 |
11.6 |
| oct |
None |
None |
84.1 |
77.1 |
70.2 |
63.3 |
56.5 |
49.9 |
43.5 |
37.5 |
32.0 |
27.4 |
24.3 |
23.1 |
| nov |
None |
None |
87.8 |
81.3 |
74.5 |
68.3 |
61.8 |
56.0 |
50.2 |
45.3 |
40.7 |
37.4 |
35.1 |
34.4 |
| dec |
None |
None |
None |
84.3 |
78.0 |
71.8 |
66.1 |
60.5 |
55.6 |
50.9 |
47.2 |
44.2 |
42.4 |
41.8 |
Because this is a table for presentation, we can’t neglect using tab_header(). A title and subtitle can provide just enough information to guide the reader out through your table visualization.