ggplot2 facet_grid() in R: Grid Layouts With Free Scales
The facet_grid() function in ggplot2 splits one plot into a 2D matrix of panels defined by a row variable and a column variable. Rows and columns become coordinates, so cross-group comparison along either axis is read at a glance.
p + facet_grid(rows = vars(drv)) # rows only p + facet_grid(cols = vars(cyl)) # columns only p + facet_grid(drv ~ cyl) # rows by cols p + facet_grid(drv ~ cyl, scales = "free_y") # free y per row p + facet_grid(drv ~ cyl, space = "free_x") # column width by data p + facet_grid(drv ~ cyl, labeller = label_both) # "drv: 4" labels p + facet_grid(drv ~ cyl, margins = TRUE) # add total panels p + facet_grid(drv ~ cyl, switch = "y") # strips on the left
Need explanation? Read on for examples and pitfalls.
What facet_grid() does in one sentence
facet_grid() lays out panels on a strict 2D matrix where rows are the levels of one variable and columns are the levels of another. It is the cross-tabulation primitive for ggplot2: same geoms, one panel per (row_level, column_level) cell, including empty cells.
Use it when you want to compare a relationship across two categorical splits at once. For one-variable faceting, prefer facet_wrap(); for cross-tabulation, facet_grid() is the right tool.
Syntax
facet_grid() is a layer added to a ggplot() call that takes either a formula (rows ~ cols) or the explicit rows/cols arguments. Optional arguments control scale linkage, panel sizing, labels, strip placement, and margin totals.
The full signature:
facet_grid(rows = NULL, cols = NULL, scales = "fixed", space = "fixed",
shrink = TRUE, labeller = "label_value", as.table = TRUE,
switch = NULL, drop = TRUE, margins = FALSE, axes = "margins",
axis.labels = "all")
You can pass rows and cols as vars(varname), or use the older formula form rowvar ~ colvar. Use a dot for "no faceting on this axis": facet_grid(. ~ cyl) is columns only; facet_grid(drv ~ .) is rows only.
vars() over the formula form in new code. facet_grid(rows = vars(drv), cols = vars(cyl)) is the tidy-evaluation idiom and pairs cleanly with programmatic faceting (passing variable names as function arguments). The formula form drv ~ cyl still works and stays common in older tutorials.Seven common patterns
1. Basic facet_grid by rows and columns
facet_grid(drv ~ cyl) creates a matrix with one row per drv level (4, f, r) and one column per cyl level (4, 5, 6, 8). Empty cells (e.g. rear-wheel-drive 4-cylinder cars) are kept and stay blank, which is the defining behavior of grid faceting.
2. Rows only or columns only
A dot on either side of the formula means "no faceting on this axis". drv ~ . gives one row per drv level, stacked vertically. Swap to . ~ drv for the same panels arranged horizontally.
3. Free scales per row or column
Unlike facet_wrap(), where scales = "free_y" frees each panel independently, facet_grid() frees each row (with "free_y") or each column (with "free_x") but keeps the linked axis consistent within the row or column. This preserves the grid's row-vs-row and column-vs-column readability.
4. Proportional panel sizes with space
The space argument is unique to facet_grid(): panels can take width or height proportional to their data range instead of every panel having the same size. Set space = "free_x" with scales = "free_x" so wider data ranges get wider panels, which avoids whitespace and visual distortion.
5. Marginal totals
margins = TRUE appends an "(all)" row, an "(all)" column, and one grand-total panel. Each marginal panel re-aggregates the data across that dimension, so the bottom row shows each cylinder count pooled over drive types, and the rightmost column shows each drive type pooled over cylinders. Useful for sanity-checking that subgroup patterns match the overall pattern.
facet_grid() is a cross-tab; facet_wrap() is a rectangle of small multiples. Grid faceting preserves the meaning of row position and column position (they are coordinates of a categorical variable), so you can read down a column and across a row as comparisons. Wrap faceting flattens panels into a sequence and arranges them to fit the page. Choose grid when both axes carry meaning; choose wrap when only one does.6. Custom labellers and strip position
Default labels show only the value (f, 8). label_both shows drv: f and cyl: 8. The switch argument moves strip labels: "x" moves column strips to the bottom, "y" moves row strips to the left, and "both" does both at once. This pairs well with theme(strip.placement = "outside") when an axis label would otherwise overlap the strip.
7. Programmatic faceting with vars()
The vars() form is required when faceting variables come from function arguments. Pair it with curly-curly ({{ }}) inside a function so the caller can pass bare column names: plot_by(mpg, drv, cyl). Trying to pass drv ~ cyl programmatically forces awkward string parsing; the vars() form is the supported path.
facet_grid() vs facet_wrap()
Use facet_grid() when row and column positions both carry meaning; use facet_wrap() when one variable wraps for layout. Both share scale, theme, and labelling defaults but answer different layout questions.
| Feature | facet_grid() | facet_wrap() |
|---|---|---|
| Panel layout | strict row x col matrix | 1D sequence wrapped to fit |
| Empty combinations | always shown (blank) | dropped by default |
| Scale freeing | per row or per column | per panel |
space = "free" |
yes (proportional panels) | no |
margins = TRUE |
yes (totals) | no |
nrow / ncol control |
inferred from variables | yes |
| Best for | two variables, cross-tab reading | one variable, many levels |
Rule of thumb: if you can frame the question as "how does Y depend on X across (row, column) cells", reach for facet_grid(). If the question is "show me one panel per group, please wrap them to fit", reach for facet_wrap().
Common pitfalls
Pitfall 1: empty cells when one combination has no data. facet_grid(manufacturer ~ class) on mpg produces dozens of empty cells because most manufacturers do not build every class. Filter to a balanced subset first, or switch to facet_wrap(~ manufacturer + class) to drop empties.
Pitfall 2: free scales break cross-row comparison. scales = "free_y" lets each row pick its own y range, which is great inside the row but misleading across rows. Reserve free scales for cases where the within-row distribution matters more than the across-row magnitude.
scales = "free_y" in facet_grid(), columns still share an x axis. If you wanted both axes free, write scales = "free". The four valid values are "fixed" (default), "free_x", "free_y", and "free".Pitfall 3: many levels make tiny panels. A 6x8 grid yields 48 panels, each too small to read. Cap each axis at 4 to 6 levels, or lump rare ones with forcats::fct_lump().
Try it yourself
Try it: Facet mpg by drv (rows) and cyl (columns) with free y axis per row. Use geom_point() for displ vs hwy. Save the plot to ex_grid.
Click to reveal solution
Explanation: drv ~ cyl puts drv levels on the rows and cyl levels on the columns. scales = "free_y" lets each row find its own y range while keeping x and column-wise comparisons consistent.
Related ggplot2 functions
After mastering facet_grid(), look at:
facet_wrap(): wrap one variable's levels into a compact rectanglevars(): tidy-evaluation helper for programmatic facetinglabel_both(),label_value(),label_parsed(),label_wrap_gen(): built-in labellersas_labeller(): turn a named character vector into a labellertheme(strip.text, strip.background, strip.placement): style panel stripscoord_cartesian(): zoom inside panels without dropping points
For paginated grids, see ggforce::facet_grid_paginate(). For interactive grids, wrap with plotly::ggplotly().
facet_grid("drv ~ cyl") in plotnine matches facet_grid(drv ~ cyl) in R. Coming from base R par(mfrow = c(3, 4))? That hard-codes a global plotting grid; facet_grid() is local to one ggplot object, handles data subsetting, and adds strip labels for free.FAQ
What is the difference between facet_grid and facet_wrap in ggplot2?
facet_grid() arranges panels in a strict matrix where row and column positions match levels of two variables; empty combinations stay visible. facet_wrap() arranges panels in a 1D sequence wrapped to fit the page; empty combinations are dropped. Use grid when both axes carry meaning, wrap when only one does.
How do you use free scales in facet_grid?
Pass scales = "free_y", "free_x", or "free". Inside facet_grid(), "free_y" frees the y axis per row (not per panel), and "free_x" frees the x axis per column. This preserves cross-column or cross-row comparison along the linked axis.
How do I change labels in facet_grid?
Set the labeller argument: labeller = label_both shows var: value, labeller = label_wrap_gen(width = 15) wraps long names. For full control, build a labeller with as_labeller(c("4" = "4 cylinders", "8" = "8 cylinders")) or recode the variable before plotting.
How do I add row and column totals to facet_grid?
Set margins = TRUE on facet_grid(). ggplot appends an "(all)" row, an "(all)" column, and a grand-total cell, each showing the data pooled along that dimension. Use this to verify that subgroup patterns reflect the overall pattern.
Why are there empty panels in my facet_grid plot?
facet_grid() keeps every (row, column) cell, even when no rows match. This is by design so the grid stays rectangular. To drop empty combinations, use facet_wrap(~ row_var + col_var) instead.
For the canonical reference, see the ggplot2 facet_grid documentation.