import polars as pl
from great_tables import GT, from_column, style, loc
from great_tables.data import airquality
air_head = airquality.head()
gt_air = GT(air_head)
gt_pl_air = GT(pl.from_pandas(air_head))Styling the Table Body
Visual styling helps draw attention to important values and makes patterns in your data easier to spot. Great Tables provides a flexible system for applying fills, borders, and text styles to cells in the table body. This page covers the fundamentals of cell-level styling, from targeting specific cells to using column values and expressions to drive dynamic styles.
Targeted styling is most effective when used sparingly to draw attention to specific values (outliers, thresholds, or key comparisons). Overusing color and borders can make a table harder to read rather than easier. A good rule of thumb: if everything is highlighted, nothing is. Start with the most important insight you want the reader to take away, and style just enough to guide their eye there.
Great Tables can add styles (like color, text properties, and borders) on many different parts of the displayed table. The following set of examples shows how to set styles on the body of table, where the data cells are located.
For the examples on this page, we’ll use the included airquality dataset to set up GT objects for both Pandas and Polars DataFrames.
When using Great Tables with VS Code, the IDE suppresses some forms of table styling displayed in notebooks. For example, border styles might not appear. Use .show("browser") to see the styled GT table in a separate browser window.
Style basics
We use the tab_style() method in combination with loc.body() to set styles on cells of data in the table body. For example, the table-making code below applies a yellow background color to specific cells.
gt_air.tab_style(
style=style.fill(color="yellow"),
locations=loc.body(columns="Temp", rows=[1, 2])
)| Ozone | Solar_R | Wind | Temp | Month | Day |
|---|---|---|---|---|---|
| 41.0 | 190.0 | 7.4 | 67 | 5 | 1 |
| 36.0 | 118.0 | 8.0 | 72 | 5 | 2 |
| 12.0 | 149.0 | 12.6 | 74 | 5 | 3 |
| 18.0 | 313.0 | 11.5 | 62 | 5 | 4 |
| None | None | 14.3 | 56 | 5 | 5 |
There are two important arguments to tab_style(): style= and locations=. We are calling a specific function for each of these:
- style.fill(): the type of style to apply. In this case the fill (or background color).
- loc.body(): the area we want to style. In this case, it’s the table body with specific columns and rows specified.
In addition to style.fill(), several other styling functions exist. We’ll look at styling borders and text in the following sections.
Customizing Borders
Let’s use style.borders() to place borders around targeted cells. In this next example, the table has a red dashed border above two rows.
gt_air.tab_style(
style=style.borders(sides="top", color="red", style="dashed", weight="3px"),
locations=loc.body(rows=[1, 2])
)| Ozone | Solar_R | Wind | Temp | Month | Day |
|---|---|---|---|---|---|
| 41.0 | 190.0 | 7.4 | 67 | 5 | 1 |
| 36.0 | 118.0 | 8.0 | 72 | 5 | 2 |
| 12.0 | 149.0 | 12.6 | 74 | 5 | 3 |
| 18.0 | 313.0 | 11.5 | 62 | 5 | 4 |
| None | None | 14.3 | 56 | 5 | 5 |
The red dashed border appears above rows 1 and 2, providing a visual separator. You can control the side ("top", "bottom", "left", "right"), color, style, and weight of the border.
Customizing Text
We can style text with by using the style.text() function. This gives us many customization possibilities for any text we target. For example, the Solar_R column below has green, bolded text in a custom font.
gt_air.tab_style(
style=style.text(color="green", font="Times New Roman", weight="bold"),
locations=loc.body(columns="Solar_R")
)| Ozone | Solar_R | Wind | Temp | Month | Day |
|---|---|---|---|---|---|
| 41.0 | 190.0 | 7.4 | 67 | 5 | 1 |
| 36.0 | 118.0 | 8.0 | 72 | 5 | 2 |
| 12.0 | 149.0 | 12.6 | 74 | 5 | 3 |
| 18.0 | 313.0 | 11.5 | 62 | 5 | 4 |
| None | None | 14.3 | 56 | 5 | 5 |
The Solar_R column text appears in green, bold, and in the Times New Roman font. The style.text() function supports additional options like size=, style= (italic), and decorate= (underline, line-through).
Column-based Styles
In addition to setting styles to specific values (e.g., a "yellow" background fill), you can also use parameter values from table columns to specify styles. The way to do this is to use the from_column() helper function to access those values.
Column-based styling is powerful because it lets the data itself drive the visual presentation. Instead of manually specifying which cells to highlight, you define a rule and let it apply automatically. This is especially valuable for tables that are regenerated with updated data, since the styling adapts without any code changes.
df = pl.DataFrame({"x": [1, 2], "background": ["lightyellow", "lightblue"]})
(
GT(df)
.tab_style(
style=style.fill(color=from_column(column="background")),
locations=loc.body(columns="x")
)
)| x | background |
|---|---|
| 1 | lightyellow |
| 2 | lightblue |
Notice that in the code above, we used values from the background column to specify the fill color for each styled row.
In the next few sections, we’ll first show how this combines nicely with the cols_hide() method, then, we’ll demonstrate how to use Polars expressions to do everything much more simply.
Combining Styling with cols_hide()
One common approach is to specify a style from a column, and then hide that column in the final output. For example, we can add a background column to our airquality data:
color_map = {
True: "lightyellow",
False: "lightblue"
}
with_color = air_head.assign(
background=(air_head["Temp"] > 70).replace(color_map)
)
with_color| Ozone | Solar_R | Wind | Temp | Month | Day | background | |
|---|---|---|---|---|---|---|---|
| 0 | 41.0 | 190.0 | 7.4 | 67 | 5 | 1 | lightblue |
| 1 | 36.0 | 118.0 | 8.0 | 72 | 5 | 2 | lightyellow |
| 2 | 12.0 | 149.0 | 12.6 | 74 | 5 | 3 | lightyellow |
| 3 | 18.0 | 313.0 | 11.5 | 62 | 5 | 4 | lightblue |
| 4 | NaN | NaN | 14.3 | 56 | 5 | 5 | lightblue |
Notice that the dataset now has a background column set to either "lightyellow" or "lightblue", depending on whether Temp is above 70.
We can then use this background column to set the fill color of certain body cells, and then hide the background column since we don’t need that in our finalized display table:
(
GT(with_color)
.tab_style(
style=style.fill(color=from_column(column="background")),
locations=loc.body(columns="Temp")
)
.cols_hide(columns="background")
)| Ozone | Solar_R | Wind | Temp | Month | Day |
|---|---|---|---|---|---|
| 41.0 | 190.0 | 7.4 | 67 | 5 | 1 |
| 36.0 | 118.0 | 8.0 | 72 | 5 | 2 |
| 12.0 | 149.0 | 12.6 | 74 | 5 | 3 |
| 18.0 | 313.0 | 11.5 | 62 | 5 | 4 |
| None | None | 14.3 | 56 | 5 | 5 |
Note the two methods used above:
- tab_style(): uses from_column() to set the color using the values of the
backgroundcolumn. - cols_hide(): prevents the
backgroundcolumn from being displayed in the output.
Using Polars expressions
Styles can also be specified using Polars expressions. For example, the code below uses the Temp column to set color to "lightyellow" or "lightblue".
# A Polars expression defines color based on `Temp`
temp_color = (
pl.when(pl.col("Temp") > 70)
.then(pl.lit("lightyellow"))
.otherwise(pl.lit("lightblue"))
)
gt_pl_air.tab_style(
style=style.fill(color=temp_color),
locations=loc.body("Temp")
)| Ozone | Solar_R | Wind | Temp | Month | Day |
|---|---|---|---|---|---|
| 41.0 | 190.0 | 7.4 | 67 | 5 | 1 |
| 36.0 | 118.0 | 8.0 | 72 | 5 | 2 |
| 12.0 | 149.0 | 12.6 | 74 | 5 | 3 |
| 18.0 | 313.0 | 11.5 | 62 | 5 | 4 |
| None | None | 14.3 | 56 | 5 | 5 |
The Polars expression evaluates per row and produces a color string for each cell. This approach avoids creating and hiding an extra column, keeping the code concise.
Each of the three approaches (from_column() with cols_hide(), Polars expressions, and functions) has its strengths. Polars expressions are the most concise for Polars DataFrames and keep the styling logic self-contained. The from_column() approach works with both Pandas and Polars backends but requires an extra column. Functions are the most flexible option for Pandas workflows. Choose whichever matches your data library and preference.
Using functions
You can also use a function, that takes the DataFrame and returns a Series with a style value for each row.
This is shown below on a pandas DataFrame.
def map_color(df):
return (df["Temp"] > 70).map(
{True: "lightyellow", False: "lightblue"}
)
gt_air.tab_style(
style=style.fill(
color=map_color),
locations=loc.body("Temp")
)| Ozone | Solar_R | Wind | Temp | Month | Day |
|---|---|---|---|---|---|
| 41.0 | 190.0 | 7.4 | 67 | 5 | 1 |
| 36.0 | 118.0 | 8.0 | 72 | 5 | 2 |
| 12.0 | 149.0 | 12.6 | 74 | 5 | 3 |
| 18.0 | 313.0 | 11.5 | 62 | 5 | 4 |
| None | None | 14.3 | 56 | 5 | 5 |
The function receives the full DataFrame and returns a Series of color values aligned with the rows. This pattern works well with Pandas when you want to derive styles from data logic without Polars expressions.
Specifying columns and rows
Using polars selectors
If you are using Polars, you can use column selectors and expressions for selecting specific columns and rows:
import polars.selectors as cs
gt_pl_air.tab_style(
style=style.fill(color="yellow"),
locations=loc.body(
columns=cs.starts_with("Te"),
rows=pl.col("Temp") > 70
)
)| Ozone | Solar_R | Wind | Temp | Month | Day |
|---|---|---|---|---|---|
| 41.0 | 190.0 | 7.4 | 67 | 5 | 1 |
| 36.0 | 118.0 | 8.0 | 72 | 5 | 2 |
| 12.0 | 149.0 | 12.6 | 74 | 5 | 3 |
| 18.0 | 313.0 | 11.5 | 62 | 5 | 4 |
| None | None | 14.3 | 56 | 5 | 5 |
See Column Selection for details on selecting columns.
Using a function
For tools like pandas, you can use a function (or lambda) to select rows. The function should take a DataFrame, and output a boolean Series.
gt_air.tab_style(
style=style.fill(color="yellow"),
locations=loc.body(
columns=lambda col_name: col_name.startswith("Te"),
rows=lambda D: D["Temp"] > 70,
)
)| Ozone | Solar_R | Wind | Temp | Month | Day |
|---|---|---|---|---|---|
| 41.0 | 190.0 | 7.4 | 67 | 5 | 1 |
| 36.0 | 118.0 | 8.0 | 72 | 5 | 2 |
| 12.0 | 149.0 | 12.6 | 74 | 5 | 3 |
| 18.0 | 313.0 | 11.5 | 62 | 5 | 4 |
| None | None | 14.3 | 56 | 5 | 5 |
The function-based row selection gives you full flexibility: any callable that takes a DataFrame and returns a boolean Series can serve as a row filter.
Multiple styles and locations
We can use a list within style= to apply multiple styles at once. For example, the code below sets fill and border styles on the same set of body cells.
gt_air.tab_style(
style=[style.fill(color="yellow"), style.borders(sides="all")],
locations=loc.body(columns="Temp", rows=[1, 2]),
)| Ozone | Solar_R | Wind | Temp | Month | Day |
|---|---|---|---|---|---|
| 41.0 | 190.0 | 7.4 | 67 | 5 | 1 |
| 36.0 | 118.0 | 8.0 | 72 | 5 | 2 |
| 12.0 | 149.0 | 12.6 | 74 | 5 | 3 |
| 18.0 | 313.0 | 11.5 | 62 | 5 | 4 |
| None | None | 14.3 | 56 | 5 | 5 |
Note that you can also pass a list to locations=!
gt_air.tab_style(
style=style.fill(color="yellow"),
locations=[
loc.body(columns="Temp", rows=[1, 2]),
loc.body(columns="Ozone", rows=[0])
]
)| Ozone | Solar_R | Wind | Temp | Month | Day |
|---|---|---|---|---|---|
| 41.0 | 190.0 | 7.4 | 67 | 5 | 1 |
| 36.0 | 118.0 | 8.0 | 72 | 5 | 2 |
| 12.0 | 149.0 | 12.6 | 74 | 5 | 3 |
| 18.0 | 313.0 | 11.5 | 62 | 5 | 4 |
| None | None | 14.3 | 56 | 5 | 5 |
You can also combine Polars selectors with a row filtering expression, in order to select a combination of columns and rows.
import polars.selectors as cs
gt_pl_air.tab_style(
style=style.fill(color="yellow"),
locations=loc.body(
columns=cs.exclude(["Month", "Day"]),
rows=pl.col("Temp") == pl.col("Temp").max()
)
)| Ozone | Solar_R | Wind | Temp | Month | Day |
|---|---|---|---|---|---|
| 41.0 | 190.0 | 7.4 | 67 | 5 | 1 |
| 36.0 | 118.0 | 8.0 | 72 | 5 | 2 |
| 12.0 | 149.0 | 12.6 | 74 | 5 | 3 |
| 18.0 | 313.0 | 11.5 | 62 | 5 | 4 |
| None | None | 14.3 | 56 | 5 | 5 |
Lastly, you can use Polars selectors or expressions to conditionally select rows on a per-column basis.
import polars.selectors as cs
gt_pl_air.tab_style(
style=style.fill(color="yellow"),
locations=loc.body(mask=cs.all().eq(cs.all().max())),
)| Ozone | Solar_R | Wind | Temp | Month | Day |
|---|---|---|---|---|---|
| 41.0 | 190.0 | 7.4 | 67 | 5 | 1 |
| 36.0 | 118.0 | 8.0 | 72 | 5 | 2 |
| 12.0 | 149.0 | 12.6 | 74 | 5 | 3 |
| 18.0 | 313.0 | 11.5 | 62 | 5 | 4 |
| None | None | 14.3 | 56 | 5 | 5 |
The mask= argument applies row/column logic jointly, highlighting only the cells where a column’s value equals its own maximum. This is a powerful way to perform per-column conditional formatting in a single statement.
Value-driven styling with tab_style_body()
While tab_style() targets cells by their position (specific rows and columns), tab_style_body() targets cells by their values. This is useful when you want to style cells that meet a condition without having to figure out which rows and columns contain those values ahead of time.
Three matching strategies are available, in order of precedence:
fn=: a predicate function that receives each cell’s value and returnsTrueorFalsepattern=: a regex matched against the string representation of each cellvalues=: a list of exact values to match
Highlighting specific values
The simplest use case involves passing a list of values, where any cell containing one of those values gets styled.
from great_tables import style, exibble
(
GT(exibble[["num", "char", "currency", "row", "group"]], rowname_col="row", groupname_col="group")
.tab_style_body(
style=style.fill(color="orange"),
values=[49.95, 33.33],
)
)| num | char | currency | |
|---|---|---|---|
| grp_a | |||
| row_1 | 0.1111 | apricot | 49.95 |
| row_2 | 2.222 | banana | 17.95 |
| row_3 | 33.33 | coconut | 1.39 |
| row_4 | 444.4 | durian | 65100.0 |
| grp_b | |||
| row_5 | 5550.0 | None | 1325.81 |
| row_6 | None | fig | 13.255 |
| row_7 | 777000.0 | grapefruit | None |
| row_8 | 8880000.0 | honeydew | 0.44 |
Using a predicate function
For more flexibility, pass a function to fn=. The function receives the raw cell value and should return True to apply the style. Missing values (NaN in Pandas) are normalized to None before being passed to your function, so x is None works reliably.
(
GT(
exibble[["num", "char", "currency", "row", "group"]],
rowname_col="row",
groupname_col="group"
)
.tab_style_body(
style=style.text(color="red"),
fn=lambda x: x is None,
)
.sub_missing(missing_text="NA")
)| num | char | currency | |
|---|---|---|---|
| grp_a | |||
| row_1 | 0.1111 | apricot | 49.95 |
| row_2 | 2.222 | banana | 17.95 |
| row_3 | 33.33 | coconut | 1.39 |
| row_4 | 444.4 | durian | 65100.0 |
| grp_b | |||
| row_5 | 5550.0 | NA | 1325.81 |
| row_6 | NA | fig | 13.255 |
| row_7 | 777000.0 | grapefruit | NA |
| row_8 | 8880000.0 | honeydew | 0.44 |
The predicate function is the most flexible option. Here we style all numeric cells with a value under 50:
(
GT(
exibble[["num", "char", "currency", "row", "group"]],
rowname_col="row",
groupname_col="group"
)
.tab_style_body(
style=style.fill(color="lightgreen"),
fn=lambda x: x >= 0 and x < 50,
)
)| num | char | currency | |
|---|---|---|---|
| grp_a | |||
| row_1 | 0.1111 | apricot | 49.95 |
| row_2 | 2.222 | banana | 17.95 |
| row_3 | 33.33 | coconut | 1.39 |
| row_4 | 444.4 | durian | 65100.0 |
| grp_b | |||
| row_5 | 5550.0 | None | 1325.81 |
| row_6 | None | fig | 13.255 |
| row_7 | 777000.0 | grapefruit | None |
| row_8 | 8880000.0 | honeydew | 0.44 |
If the function raises an exception for a cell (e.g., comparing a string with >=), that cell is silently skipped.
Using a regex pattern
The pattern= argument matches against the string representation of each cell. None values are skipped.
(
GT(
exibble[["num", "char", "currency", "row", "group"]],
rowname_col="row",
groupname_col="group"
)
.tab_style_body(
style=style.fill(color="lightyellow"),
pattern="^[a-d]",
)
)| num | char | currency | |
|---|---|---|---|
| grp_a | |||
| row_1 | 0.1111 | apricot | 49.95 |
| row_2 | 2.222 | banana | 17.95 |
| row_3 | 33.33 | coconut | 1.39 |
| row_4 | 444.4 | durian | 65100.0 |
| grp_b | |||
| row_5 | 5550.0 | None | 1325.81 |
| row_6 | None | fig | 13.255 |
| row_7 | 777000.0 | grapefruit | None |
| row_8 | 8880000.0 | honeydew | 0.44 |
Scoping with columns= and rows=
You can restrict which cells are considered for matching by providing columns= and rows=, exactly as you would with loc.body().
(
GT(
exibble[["num", "char", "currency", "row", "group"]],
rowname_col="row",
groupname_col="group"
)
.tab_style_body(
style=style.fill(color="lightcyan"),
fn=lambda x: x >= 0 and x < 50,
columns="num",
)
)| num | char | currency | |
|---|---|---|---|
| grp_a | |||
| row_1 | 0.1111 | apricot | 49.95 |
| row_2 | 2.222 | banana | 17.95 |
| row_3 | 33.33 | coconut | 1.39 |
| row_4 | 444.4 | durian | 65100.0 |
| grp_b | |||
| row_5 | 5550.0 | None | 1325.81 |
| row_6 | None | fig | 13.255 |
| row_7 | 777000.0 | grapefruit | None |
| row_8 | 8880000.0 | honeydew | 0.44 |
Expanding matches with targets=
By default, only the matched cell itself is styled (targets="cell"). You can expand the styling to entire rows or columns that contain a match:
targets="row": style the full row of each matchtargets="column": style the full column of each matchtargets=["row", "column"]: both
(
GT(
exibble[["num", "char", "currency", "row", "group"]],
rowname_col="row",
groupname_col="group"
)
.tab_style_body(
style=style.fill(color="lightblue"),
values=[49.95],
targets="row",
)
.tab_style_body(
style=style.fill(color="bisque"),
values=[33.33],
targets="column",
)
)| num | char | currency | |
|---|---|---|---|
| grp_a | |||
| row_1 | 0.1111 | apricot | 49.95 |
| row_2 | 2.222 | banana | 17.95 |
| row_3 | 33.33 | coconut | 1.39 |
| row_4 | 444.4 | durian | 65100.0 |
| grp_b | |||
| row_5 | 5550.0 | None | 1325.81 |
| row_6 | None | fig | 13.255 |
| row_7 | 777000.0 | grapefruit | None |
| row_8 | 8880000.0 | honeydew | 0.44 |
Projecting into the stub with extents=
When your table has a stub (row labels), you can extend the styling into it with extents=["body", "stub"]. This is especially useful with targets="row" to highlight an entire row including its label.
(
GT(
exibble[["num", "char", "currency", "row", "group"]],
rowname_col="row",
groupname_col="group"
)
.tab_style_body(
style=style.fill(color="lightblue"),
values=[49.95],
targets="row",
extents=["body", "stub"],
)
)| num | char | currency | |
|---|---|---|---|
| grp_a | |||
| row_1 | 0.1111 | apricot | 49.95 |
| row_2 | 2.222 | banana | 17.95 |
| row_3 | 33.33 | coconut | 1.39 |
| row_4 | 444.4 | durian | 65100.0 |
| grp_b | |||
| row_5 | 5550.0 | None | 1325.81 |
| row_6 | None | fig | 13.255 |
| row_7 | 777000.0 | grapefruit | None |
| row_8 | 8880000.0 | honeydew | 0.44 |
Learning more
The combination of tab_style(), tab_style_body(), location specifiers, and the various style functions gives you precise control over the visual presentation of your table body. Whether you are highlighting outliers, applying conditional formatting, or using column-driven styles, these tools let you communicate data insights through visual cues that complement the numeric values themselves.
For further reference, consult the API documentation: