5  Data

5.1 Binding a table

Where does a plot get its numbers? data() takes a data frame directly from your R environment. Column names after that are bare words: no quotes, no $, no indexing.

data(gapminder_2007) + point + x(gdp) + y(life)
data(gapminder_2007) + point + x(col.gdp) + y(col.life)
data(gapminder_2007) + point + x(:gdp) + y(:life)
plot(data(gapminder_2007), point, x(col.gdp), y(col.life))
0K 10K 20K 30K 40K 50K 40 50 60 70 80 Life Gdp

“Given gapminder 2007: points, x is gdp, y is life.”

gog looks up gdp and life inside the gapminder_2007 data frame automatically. This is the Bind-Once Law: name the table once, then use columns freely.

gapminder_2007 here is just a regular R data frame (or tibble) in your workspace: no registration step, no special wrapping required.

Writing data() is not just a formality. When you write data(gapminder_2007), gog keeps the name gapminder_2007, not only the numbers inside it. The name matters as soon as a plot has more than one table, because each layer looks up its bare column names in the nearest table by name. Leave data() out and the table arrives with no name, so the plot refuses and tells you what to write:

gapminder_2007 + point + x(gdp) + y(life)
Error:
! gog: a plot starts with `data()`, which names the table — columns are bare names and the nearest named table wins, so the name matters. Write `data(gapminder_2007) + point + ...`.

The same advice comes back if a bare frame appears mid-expression (write + data(forecast), not + forecast) or if the sentence has no data at all (point + x(gdp) with nothing before it).

5.2 The table has to be tidy

Every table in this book has the same shape. Each row is one observation, and each column is one variable. That shape has a name, tidy data (Wickham, 2014), and gog assumes it in every sentence you write.

The reason is a rule you have already read. A channel takes a column, so anything you want to map has to be a column. Suppose you record sales for two regions, and you give each region a column of its own:

wide <- data.frame(year  = 2019:2023,
                   north = c(120, 135, 128, 152, 168),
                   south = c( 90, 101, 140, 133, 155))

data(wide) + line + x(year) + y(sales) + color(region)
Error:
! gog: `y(sales)` refers to a column that is not in the data. Check the spelling of `sales`.
gog: `color(region)` refers to a column that is not in the data. Check the spelling of `region`.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.

The message names two problems. There is no column called sales, and no column called region. Your two regions are column names, and a name is not a column. Your sales numbers sit in two columns instead of one. The sentence asks for columns the table does not have, so gog draws nothing.

The same numbers, one row per observation, draw the plot and earn the legend:

long <- data.frame(year   = rep(2019:2023, times = 2),
                   region = rep(c("north", "south"), each = 5),
                   sales  = c(120, 135, 128, 152, 168,
                               90, 101, 140, 133, 155))

data(long) + line + x(year) + y(sales) + color(region)
2019 2020 2021 2022 2023 100 120 140 160 Sales Year Region north south

gog never reshapes a table, and no transform turns columns into rows. You do that work before you write the sentence, in the language you are already using. R has tidyr::pivot_longer(). Python has pandas.melt(). Julia has DataFrames.stack(). JavaScript has no standard table library, so you write a short loop instead.

gog is stricter than ggplot2 here, but not about the shape. ggplot2 does not reshape tables either, and it refuses the wide table above just as gog does. The real difference is narrower. ggplot2 lets you calculate a value while you map it, so aes(x = log(gdp)) works there. gog does not allow that, because a channel takes a column name and nothing else. Some of those calculations are already part of the grammar. A log axis is one of them, written x(gdp, scale = "log"). For anything else, add the column to your table first. So tidy is not quite enough on its own. Every column your sentence names has to be in the table already.

This book does not teach you how to tidy data, and it should not, because there is a better book for it. Read R for Data Science by Hadley Wickham, Mine Çetinkaya-Rundel and Garrett Grolemund (Wickham et al., 2023), which is free online at https://r4ds.hadley.nz/. Its chapter on data tidying is the one that matters here. If the shape of your data is not settled yet, read that chapter before you read this one.

5.3 Plot-level vs. layer-level data

data() at the start of the expression is the plot-level default: it applies to all layers that don’t declare their own table.

# Both layers read from "gapminder_2007"
data(gapminder_2007) + x(gdp) + y(life) +
  line +
  point
(data(gapminder_2007) + x(col.gdp) + y(col.life) +
  line +
  point)
data(gapminder_2007) + x(:gdp) + y(:life) + line + point
plot(data(gapminder_2007), x(col.gdp), y(col.life), line, point)
0K 10K 20K 30K 40K 50K 40 50 60 70 80 Life Gdp

A data() that appears between two marks overrides the table for the layer that follows it:

# line uses "actuals"; point uses "forecast"
data(actuals) + x(year) + y(sales) +
  line +
  data(forecast) + point
(data(actuals) + x(col.year) + y(col.sales) +
  line +
  data(forecast) + point)
data(actuals) + x(:year) + y(:sales) + line + data(forecast) + point
plot(data(actuals), x(col.year), y(col.sales), line, data(forecast),
  point)
2020 2022 2024 2026 120 140 160 180 200 Sales Year

The rule: each data() applies to the mark written directly after it. A mark with no data() in front of it reads the plot-level table.

Two things follow from that rule, and both are easy to miss.

A data() at the end of a sentence applies to nothing, because no mark comes after it. The plot is the same as it would be without that line:

# `forecast` is never drawn, because no mark comes after it
data(actuals) + x(year) + y(sales) +
  line +
  point +
  data(forecast)
(data(actuals) + x(col.year) + y(col.sales) +
  line +
  point +
  data(forecast))
data(actuals) + x(:year) + y(:sales) + line + point + data(forecast)
plot(data(actuals), x(col.year), y(col.sales), line, point,
  data(forecast))
2019 2020 2021 2022 2023 120 130 140 150 160 170 Sales Year

A data() also stops after that one mark. The mark after it reads the plot-level table again:

# line reads actuals. point reads forecast. area reads actuals again.
data(actuals) + x(year) + y(sales) +
  line +
  data(forecast) + point +
  area
(data(actuals) + x(col.year) + y(col.sales) +
  line +
  data(forecast) + point +
  area)
data(actuals) + x(:year) + y(:sales) + line + data(forecast) + point +
  area
plot(data(actuals), x(col.year), y(col.sales), line, data(forecast),
  point, area)
2020 2022 2024 2026 0 100 200 Sales Year

The two tables cover different years, which makes the change easy to see. actuals runs from 2019 to 2023, and forecast from 2024 to 2026. The area and the line both sit on the left. The points sit alone on the right.

That rule fits what a second table is usually for. The first table builds the frame, and a later one annotates it: a threshold, a band, a note, a forecast. An annotation is one mark, so an extra data() that reaches one mark is a fit rather than a limit. Every second table in this book is one of those.

To draw two marks from the second table, write data() before each of them.

That sentence is correct, and it still reads badly. area sits on the line below data(forecast), so it looks like part of the forecast. Only the rule says otherwise.

This is style, not grammar. Both spellings are legal, and both build the same plot. gog will never refuse either one, because legal is not the same as clear (Law 8). Two sentences can mean the same thing and still be very different to read. That difference is yours to control.

So group the marks by the table they read. Keep each extra data() on the same line as the mark it applies to, and keep the marks that read the plot-level table together. The plot above is better written with line + area on one line, and data(forecast) + point on the next. Every line is then one table and the marks that read it, and no mark sits under a table it does not read. The rest of this book follows that layout.

The first data() in a sentence does apply to every mark below it, so a line of its own suits it. A later one never does. Giving a later one its own line makes it look like it reaches further than it does.

5.4 When the second table names its columns differently

The plot above was the easy case: actuals and forecast both call their columns year and sales, so one pair of positions written at the front served both layers.

A second table usually does not match. The milestones table marks two points on the income axis, and its columns are at, value and note. None of those names appear in gapminder_2007, whose columns are gdp and life. A layer may name its own columns, and the axis does not change:

data(gapminder_2007) + point + x(gdp) + y(life) +
  data(milestones) + text + x(at) + y(value) + label(note) +
  style(size = 20, color = "red")
(data(gapminder_2007) + point + x(col.gdp) + y(col.life) +
  data(milestones) + text + x(col.at) + y(col.value) + label(col.note) +
  style(size = 20, color = "red"))
data(gapminder_2007) + point + x(:gdp) + y(:life) + data(milestones) +
  text + x(:at) + y(:value) + label(:note) +
  style(size = 20, color = "red")
plot(data(gapminder_2007), point, x(col.gdp), y(col.life),
  data(milestones), text, x(col.at), y(col.value), label(col.note),
  style({ size: 20, color: "red" }))
income takes off the long plateau 0K 10K 20K 30K 40K 50K 40 50 60 70 80 Life Gdp

There is still one x axis and one y axis, one scale each, one set of ticks. Only the names differ, because the two tables were written by different people for different reasons.

What is shared is the measurement, and that is what an axis is. at holds income and value holds years of life, the same two quantities gdp and life hold. A table whose numbers mean something else does not belong on these axes, however its columns are spelled: put sales figures on an income axis and the plot is drawn correctly and reads as nonsense. Matching names are not the test. Matching meanings are.

That test decides whether the second table belongs in this plot at all. A layer joins a frame the first table built, so it has to land on the axes that frame already has. When it does not, the two tables are measuring different things, and two plots on one page say that honestly where one plot cannot. Composition is the operator for it.

A layer that names no position of its own reads the axis, whoever set it. That is why the earlier plot worked with the positions written once at the front.

5.5 A table in a database

Not every table fits on one computer. Most working data sits in a database, and a warehouse table can hold billions of rows. gog reaches those tables with one word, query, and it stands exactly where data stands.

Nothing else in the sentence changes:

data(orders)                          + bar + x(status)
query(connection, "SELECT ...")       + bar + x(status)

The SQL sits inside that one word and goes no further. After it, the columns are still bare names. The marks, channels, transforms and operators are the ones you have already learned. You are not writing a second language for databases. You are naming the rows a different way.

Here is a real database. The chunk builds a small one in memory, so it needs no server and no password:

con <- DBI::dbConnect(RSQLite::SQLite(), ":memory:")
DBI::dbWriteTable(con, "countries", gapminder_2007)

Now the sentence. The connection is the only new part:

query(con, "SELECT gdp, life, continent FROM countries WHERE gdp > 5000") +
  point + x(gdp) + y(life) + color(continent)
10K 20K 30K 40K 50K 50 60 70 80 Life Gdp Continent Europe Africa Americas Oceania Asia

The plot is an ordinary scatter plot, because the grammar did an ordinary thing. The database chose which rows to send. gog drew what arrived.

That last point is worth stating plainly. A query and a loaded table produce the same picture, byte for byte, when they hold the same rows. Every binding tests exactly that. So query is never a different way of drawing; it is a different way of finding the rows to draw.

5.5.1 Three things to know

The connection is yours. gog never opens one, and it never stores a password. You connect with whatever your language already uses, and hand the open connection to query. That also means gog adds no database driver to your install. A reader who never writes SQL never installs one.

The query runs when the plot is drawn, not when the sentence is written. Writing query(con, "...") asks the database nothing. The sentence is a specification, exactly like every other sentence in this book, and the engine draws it later. That is worth more than tidiness. A specification the engine can read before the database runs is one where the engine could rewrite the question, and ask for a summary instead of the rows. Counting is a job a database does well, and the design leaves that door open.

Every language reaches a different set of databases. The word is the same in all four. What differs is the connection you hand it, because each language has its own database convention. Your language’s chapter says which: R, Python, Julia, JavaScript.

5.5.2 How much data comes back

query() brings every row the query returns to the computer running gog. The database does the selecting. Your machine does the drawing. That is worth knowing before you point this at a large table.

For most tables it does not matter. A few thousand rows arrive quickly, and you should write the simple query and think no further. Selecting the columns you need and letting gog do the rest is the normal case, and the example above is exactly that.

It starts to matter somewhere around a hundred thousand rows. Past that, moving the rows costs more than drawing them. A warehouse table can hold billions, and SELECT * FROM that_table would try to bring all of them to you.

The way to avoid it is already familiar to anyone who works with a warehouse: summarize in SQL, then plot the summary. This works because a plot is nearly always a summary anyway. A bar chart of a billion orders shows a handful of bars, and a database can produce those bars far more cheaply than your laptop can.

query(con, "SELECT continent, COUNT(*) AS countries
            FROM countries GROUP BY continent") +
  bar + x(continent) + y(countries)
Africa Americas Asia Europe Oceania 0 20 40 Countries Continent

Five rows crossed the connection, not a hundred and forty. The plot is the same one you would get by counting locally.

The common summaries all have a SQL form:

The plot you want What to ask the database for
Bars of counts SELECT key, COUNT(*) AS n ... GROUP BY key
A histogram SELECT FLOOR(v / 5000) * 5000 AS bucket, COUNT(*) AS n ... GROUP BY 1
A time series SELECT date_trunc('month', t) AS month, SUM(v) AS total ... GROUP BY 1
A scatter plot of a huge table a sample: TABLESAMPLE, or USING SAMPLE 10000 in DuckDB

Two marks are harder. smooth and density fit a curve through the individual rows, and a curve fitted through counts is not the same curve. A database can compute them, and some tools do, but gog does not ask it to yet. For now, take a sample for those two and say so in the caption.

Writing the summary is your job today. The design is built so that it might not always be: query() holds its SQL instead of running it, which is what would let the engine send the GROUP BY itself and receive only the summary. The sentence you write would not change. That is the direction gog is built for, rather than a promise with a date on it, and the summarizing is yours until it arrives.

5.5.3 Two directions, and which one suits you

There is another way to give SQL users a grammar of graphics. Posit’s ggsql (Posit Software, PBC, 2026) adds visual clauses to SQL itself: you write VISUALIZE, DRAW and SCALE inside the query, and the database returns a picture.

It has two strengths and both are real. Its clauses read like SQL, so someone who writes SQL all day never leaves the language they are thinking in, and no host language is installed at all. And it runs the whole pipeline in the database, one query per layer, so a bar chart of ten billion rows fetches only the bar heights. On that second point ggsql is ahead of gog, which does not do this today. The limit described above is ours, not the idea’s.

If SQL is where you work, ggsql is probably the better fit. gog would first ask you to pick up R, Python, Julia or JavaScript, and that is a real cost for someone with no other reason to.

gog points the other way. The grammar stays in one place and the data comes to it, so one sentence draws a loaded table, a table in Postgres, and a table in a warehouse, in any of four languages. One syntax, as many languages and settings as we can reach.

You work The lower cost is
Only in SQL ggsql: nothing to install, and the syntax is the one you use
Across several languages gog: one grammar covers all of them, and the database too

5.6 Passing data in R

Pass any R data frame or tibble directly, no setup required:

library(gog)

# gapminder_df is a regular data frame in your environment
data(gapminder_df) + point + x(gdp) + y(life) + color(continent)

That first line prints a long list of masked names, and it is worth knowing now that most of the list cannot affect you. mean(x) and sum(x) keep working, because R skips a name that is not a function when it resolves a call. Eight names do take over, data among them, and the R chapter lists all eight with the qualified form of each.

Today the table crosses to the engine as an in-memory DataFrame; Apache Arrow (zero-copy, no serialization penalty even for millions of rows) is planned to replace it.

5.7 Factors decide the order of categories

A plain text column has no order of its own, so gog draws its categories in the order the rows happen to arrive:

severity <- data.frame(
  level = c("High", "Low", "Medium"),
  count = c(30.0, 10.0, 20.0)
)

data(severity) + bar + x(level) + y(count) +
  title("Text column: the order the rows came in")
severity = {"level": ["High", "Low", "Medium"], "count": [30.0, 10.0, 20.0]}
(data(severity) + bar + x(col.level) + y(col.count) +
  title("Text column: the order the rows came in"))
severity = (level = ["High", "Low", "Medium"], count = [30, 10, 20],)
data(severity) + bar + x(:level) + y(:count) +
  title("Text column: the order the rows came in")
const severity = { level: ["High", "Low", "Medium"], count: [30, 10, 20] };
plot(data(severity), bar, x(col.level), y(col.count),
  title("Text column: the order the rows came in"))
High Low Medium 0 10 20 30 Text column: the order the rows came in Count Level

That is rarely what you want here. Make it a factor and say what order you mean:

severity$level <- factor(severity$level, levels = c("Low", "Medium", "High"))

data(severity) + bar + x(level) + y(count) +
  title("Factor: the order you declared")
Low Medium High 0 10 20 30 Factor: the order you declared Count Level

The levels = travels with the column, so every part of the chart agrees: the axis, the color assignment, and the legend all read in the same order.

data(severity) + point + x(count) + y(count) + color(level) +
  title("The legend follows the same order")
(data(severity) + point + x(col.count) + y(col.count) + color(col.level) +
  title("The legend follows the same order"))
data(severity) + point + x(:count) + y(:count) + color(:level) +
  title("The legend follows the same order")
plot(data(severity), point, x(col.count), y(col.count), color(col.level),
  title("The legend follows the same order"))
10 15 20 25 30 10 15 20 25 30 The legend follows the same order Count Count Level Low Medium High

The order holds even when gog does the counting for you. These tickets arrive High first, but bar * count still reads Low, Medium, High. A summary carries the declared order onto its axis, it does not fall back to first-appearance:

tickets <- data.frame(
  level = factor(
    c("High", "Low", "High", "Medium", "High", "Low", "Medium", "High"),
    levels = c("Low", "Medium", "High")
  )
)

data(tickets) + bar * count + x(level) +
  title("bar * count: the levels still order the axis")
Low Medium High 0 1 2 3 4 bar * count: the levels still order the axis Count Level

R has two kinds of factor: factor(x, ordered = TRUE), meaning Low really is less than High, and a plain factor that merely lists its levels. gog honors the order in both cases, because most people reach for a plain factor purely to fix the display order and mean nothing mathematical by it.

5.7.1 The same declaration in the other three languages

factor() is R’s word for this. Python, Julia and JavaScript have no factor type, so gog supplies the declaration itself, under one name: ordered(). It takes the column’s values and the order its categories go in. Declare it where you build the table, and everything after that reads the same in all four languages.

R

severity <- data.frame(
  level = factor(c("High", "Low", "Medium"), levels = c("Low", "Medium", "High")),
  count = c(30.0, 10.0, 20.0)
)

Python

severity = {
    "level": ordered(["High", "Low", "Medium"], ["Low", "Medium", "High"]),
    "count": [30.0, 10.0, 20.0],
}

Julia

severity = Dict(
    "level" => ordered(["High", "Low", "Medium"], ["Low", "Medium", "High"]),
    "count" => [30.0, 10.0, 20.0],
)

JavaScript

const severity = {
  level: ordered(["High", "Low", "Medium"], ["Low", "Medium", "High"]),
  count: [30.0, 10.0, 20.0],
};

Python reads a pandas Categorical as well, and takes its categories as the levels. ordered() is what a plain dictionary of lists uses, because gog does not require pandas.

Two rules worth knowing:

  • Levels say what order. The data says what is there. A level with no rows draws no bar and gets no slot on the axis: an empty labeled gap is harder to read than a shorter axis. A value in the data that is not in the levels is never dropped; it goes on the end.
  • An explicit order() wins. You wrote it in the plot, which is nearer than the table. Name a different column and it overrides the levels, which is what lets you sort by value:
data(severity) + bar + x(level) + y(count) + order(count, desc = TRUE) +
  title("order() overrides the levels")
(data(severity) + bar + x(col.level) + y(col.count) + order(col.count, desc = True) +
  title("order() overrides the levels"))
data(severity) + bar + x(:level) + y(:count) +
  order(:count, desc = true) + title("order() overrides the levels")
plot(data(severity), bar, x(col.level), y(col.count),
  order(col.count, { desc: true }), title("order() overrides the levels"))
High Medium Low 0 10 20 30 order() overrides the levels Count Level

Name the category column itself and you are asking for that column’s own order, so there is nothing to override. A plain text column carries nothing but its spelling and sorts alphabetically; a factor carries its levels, so it keeps them, and desc = TRUE reverses them. That leaves no way to alphabetize a factor’s labels from inside the plot, and it is the right thing to lose: relevel it in the host, where the levels are.

5.8 Missing values

Real data has gaps: penguins is missing two flipper measurements, and most survey data far more. A row with no value where the plot needs one cannot be placed, so gog drops it and says how many, rather than quietly changing the count under you:

patchy <- data.frame(
  weight = c(3.1, 4.2, NA, 5.8, 6.0, NA, 4.9, 5.1, 3.7, 5.5),
  batch  = c("A", "A", "B", "B", "A", "B", "A", "B", "A", "B")
)
data(patchy) + bar * bin + x(weight) +
  x_label("Weight") + title("Two rows have no weight")
patchy = {"weight": [3.1, 4.2, None, 5.8, 6.0, None, 4.9, 5.1, 3.7, 5.5], "batch": ["A", "A", "B", "B", "A", "B", "A", "B", "A", "B"]}
(data(patchy) + bar * bin + x(col.weight) +
  x_label("Weight") + title("Two rows have no weight"))
patchy = (weight = [3.1, 4.2, missing, 5.8, 6, missing, 4.9, 5.1, 3.7, 5.5], batch = ["A", "A", "B", "B", "A", "B", "A", "B", "A", "B"],)
data(patchy) + bar * bin + x(:weight) + x_label("Weight") +
  title("Two rows have no weight")
const patchy = { weight: [3.1, 4.2, null, 5.8, 6, null, 4.9, 5.1, 3.7, 5.5], batch: ["A", "A", "B", "B", "A", "B", "A", "B", "A", "B"] };
plot(data(patchy), layer(bar, bin), x(col.weight), x_label("Weight"),
  title("Two rows have no weight"))
gog: dropped 2 rows of `patchy` with a missing value in `weight` — a row with no value in a column the plot maps cannot be placed, so it is left out (the same as other plotting tools drop NA).
4 5 0 1 2 3 Two rows have no weight Count Weight

The drop is scoped to the columns the plot maps. The message names weight because weight is bound to x, and a row with no x has nowhere to stand. A missing value in a column the plot never reads costs nothing: leave batch unbound and its gaps are ignored; bind it to color and a row missing its batch drops too, because now it has no color to take. This is the rule other plotting tools follow: only the channels actually in use decide which rows survive, so a stray NA in an unused column never silently shrinks the picture.

5.9 Labels are used verbatim

Category names, titles, and axis labels are drawn exactly as they appear in your data. Characters with meaning in the output format (&, <, >) are escaped for you, so a department called R&D needs no special handling:

special <- data.frame(
  firm  = c("R&D", "Sales & Ops", "<10% unit"),
  spend = c(10, 25, 15)
)
data(special) + bar + x(firm) + y(spend) + title("Q3 <spend> & forecast")
special = {"firm": ["R&D", "Sales & Ops", "<10% unit"], "spend": [10, 25, 15]}
data(special) + bar + x(col.firm) + y(col.spend) + title("Q3 <spend> & forecast")
special = (firm = ["R&D", "Sales & Ops", "<10% unit"], spend = [10, 25, 15],)
data(special) + bar + x(:firm) + y(:spend) +
  title("Q3 <spend> & forecast")
const special = { firm: ["R&D", "Sales & Ops", "<10% unit"], spend: [10, 25, 15] };
plot(data(special), bar, x(col.firm), y(col.spend),
  title("Q3 <spend> & forecast"))
R&D Sales & Ops <10% unit 0 10 20 Q3 <spend> & forecast Spend Firm

Non-Latin scripts work the same way, including as bare column names. The table below is Korean. 지역별 (jiyeokbyeol) means by region, 지역 (jiyeok) is region, and 값 (gap) is value. The three rows are the cities 서울 (Seoul), 부산 (Busan) and 대구 (Daegu). The plot title 한국 지역별 값 (Hanguk jiyeokbyeol gap) reads value by region, Korea.

지역별 <- data.frame(
  지역 = c("서울", "부산", "대구"),
= c(10, 25, 15)
)
data(지역별) + bar + x(지역) + y(값) + title("한국 지역별 값")
서울 부산 대구 0 10 20 한국 지역별 값 지역

Label widths are measured per character, so full-width scripts reserve the right amount of margin rather than three times too much.