data(gapminder_2007) + point + x(col.gdp, scale='log') + y(col.life) + color(col.continent)42 Python
The grammar does not change when the language does. One engine reads one specification, so a sentence means what it means whoever wrote it, and everything in this book up to here is as true in Python as it is in R. What differs is small enough to state on one page, and this chapter is that page.
Every Python block below was executed to draw the plot beside it, through the same engine every other chapter uses.
42.1 A column is written col.gdp
R writes a column as a bare name, Julia as a symbol, Python through a small accessor:
The R form of that sentence is the one from A first plot, with col. in front of each column. Those four characters buy something worth paying for. In R a bare gdp cannot be anything except a name; Python has no bare names, and in this grammar a plain string is how you spell a value: style(color="tomato"), title("…"), palette("okabe"). Without the accessor a column and a value would look identical, and the grammar’s oldest rule (a channel takes a column, never a value) would be invisible in the one language that cannot see it.
So a string where a column belongs is refused, and told what to write:
data(gapminder_2007) + point + x('gdp') + y(col.life)gog: `x("gdp")` binds a *value*, and a channel takes a *column*. Python has no bare names, so a column is written with the accessor: `x(col.gdp)` maps the column called `gdp`.
The message offers both spellings because both are real: one maps a column and earns a legend, the other sets one value and earns none (Setting is not mapping). The mistake in the other direction is refused the same way:
data(gapminder_2007) + point + x(col.gdp) + y(col.life) + style(color=col.continent)gog: `style(color=)` fixes one value for the whole layer, and `col.continent` is a column. To *map* it — one value per category, with a legend to decode it — that is a channel: `color(col.continent)`.
A column whose name Python cannot spell as an attribute takes the bracket form instead, col["life exp"], which is also how you reach a name with a space or a dot in it.
42.2 A sentence over several lines takes parentheses
R continues a line that ends in +. Python ends the statement there, so a sentence spanning several lines is wrapped in parentheses:
(data(gapminder_2007) + point + x(col.gdp, scale='log') + y(col.life)
+ color(col.continent) + size(col.population)
+ title('Wealth and life expectancy, 2007'))That is Python’s rule about lines rather than anything of gog’s, and together with the accessor it accounts for almost every difference you will type.
42.3 The table
A table is a dict of columns, so a first plot needs nothing installed at all:
data({'level': ['Low', 'Medium', 'High'], 'count': [30.0, 20.0, 10.0]},
name='small') + bar + x(col.level) + y(col.count)Anything carrying .columns and df[name] is also a table, which covers pandas and polars. They are duck-typed rather than imported, so neither is a dependency of the binding, and the ordinary way in is the one you already use:
import pandas as pd
gapminder_2007 = pd.read_csv(GAPMINDER_CSV)
print(gapminder_2007.shape, list(gapminder_2007.columns))(142, 6) ['country', 'continent', 'year', 'life', 'population', 'gdp']
Every plot on this page reads that frame. Types cross as you would expect: numbers become positions, text becomes categories, datetime64 and Python’s own date and datetime become a calendar axis, and NaN or None is a missing value the engine drops from the rows it cannot place, saying how many. A pandas Categorical carries its declared order across, which is the counterpart of R’s factor(x, levels = …); a plain dict of strings has nowhere to put that, so there the categories fall back to the order the rows came in, which Data describes.
42.4 Computation is pandas’ job, not the grammar’s
A channel takes a column name and never an expression. There is no x(log(gdp)), and gog grows no small language for deriving values, because Python already is one: compute the column, then bind it by name.
europe = gapminder_2007[gapminder_2007.continent == 'Europe'].copy()
europe['gdp_thousands'] = europe.gdp / 1000
europe = europe.sort_values('life', ascending=False)
print(europe[['country', 'gdp_thousands', 'life']].head(3).to_string(index=False))country gdp_thousands life
Iceland 36.180789 81.757
Switzerland 37.506419 81.701
Spain 28.821064 80.941
That frame is a table like any other, and the sentence over it is the sentence you already know:
(data(europe) + point + x(col.gdp_thousands) + y(col.life)
+ size(col.population) + style(color='steelblue')
+ x_label('GDP per capita (thousands)')
+ title('Europe, 2007'))The division stayed in pandas, where the reader can see it, and the plot names a column that exists. That split is deliberate rather than a missing feature: a plot is a mapping from a table (Data), so a channel names one of its columns, and anything you would rather compute is computed before the sentence starts.
42.5 Showing the plot
In a Jupyter notebook a plot displays itself, as it does in the R console. A script displays nothing on its own, so say where the plot goes:
plot = data(gapminder_2007) + point + x(col.gdp) + y(col.life)
print(plot)
print(plot.save('/tmp/life.svg'))<gog plot: point on gapminder_2007>
/tmp/life.svg
save() writes the SVG, show() opens it in a browser, and render() returns it as a string. R’s print() opens the viewer without being asked; Python waits to be told, which is the convention each language already has.
42.6 Five names Python already uses
bin, sum, min, max and range are transforms here and builtins there, so from gog import * puts gog’s over Python’s for the rest of the file. The R package does the same thing to base::range and base::sum: a grammar keeps its own vocabulary. Calling one says so rather than failing obscurely:
try:
range(10)
except GogError as error:
print(error)gog: `range` here is gog's transform, not Python's builtin — `from gog import *` shadows `bin`, `sum`, `min`, `max` and `range`. For Python's: `from builtins import range`, or call `builtins.range(...)`. For gog's, use it bare: `bar * range`.
A module that needs both can import gog and write gog.point, or take the builtin back with from builtins import sum.
42.7 What does not change
The four operators, including how tightly they bind. * groups before +, + before |, and / sits beside *, which are Python’s own precedences as well as R’s, so a derived layer and a crossed facet group exactly as they do in Operators:
(data(gapminder_2007) + bar * mean + x(col.continent) + y(col.life)
+ order(col.life, desc=True)
+ title('Mean life expectancy, highest first'))The vocabulary does not change either: every mark, transform, channel, setting and plot-level atom in the kernel exists in Python under the same name. Neither do the refusals, because they are the engine’s and not the binding’s, word for word:
data(gapminder_2007) + bar + x(col.continent)gog: `bar` needs `y()` but none is set. Add `y(<column>)` — a bar cannot be drawn without it.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.
How far apart are the two spellings, then? That is measured rather than argued. Every sentence in this book, 481 of them, was translated by rule and run through the Python binding: 379 drew a byte-identical plot, 88 earned a word-identical refusal, and seven differed only in the example inside the message, where each binding teaches its own syntax. Seven sentences do not carry over at all, and every one of them is in R, about R’s pipes. Two rewrites account for nearly all the rest: the accessor, 471 times, and the parentheses, 326.
42.8 Reading from a database
Python has a database standard, and query() speaks it. It also speaks Spark, because that is where a large share of Python’s data actually lives.
The standard is DB-API, also called PEP 249. Anything with a .cursor() method works: the built-in sqlite3, DuckDB, psycopg, pyodbc, Snowflake’s connector, and the Databricks SQL connector.
import sqlite3
from gog import query, bar, x, y, col, render_svg
con = sqlite3.connect("sales.db")
query(con, "SELECT status, revenue FROM orders") + bar + x(col.status) + y(col.revenue)A Spark session works too. Hand query() the session itself, and the table name is whatever your catalog calls it. This is the route to a Databricks table:
query(spark, "SELECT status, revenue FROM main.sales.orders") + bar + x(col.status) + y(col.revenue)In a Databricks notebook the session is already there, under the name spark. gog installs from PyPI with its engine included, so %pip install gog is the only setup step.
Neither route adds a dependency to gog. You bring the connection; gog reads rows from it.
42.9 Getting it running
A plot is drawn by the engine, which is a compiled Rust binary, so the Python package has to be able to find one. The released wheel carries it: one wheel per platform, each holding the engine built for that platform, so installing needs no Rust toolchain.
pip install gogThat command works today. gog is on PyPI. Five wheels cover macOS, Linux and Windows, on both common processor families. Python was the first of the four bindings to be published, and it is still the shortest install line of the four. The other three now have registry commands of their own, listed in the preface.
To work from a checkout instead, build the engine once and import the package from where it sits:
cargo build --release -p gog-cliimport sys
sys.path.insert(0, "py-pkg/gog")
from gog import *The engine is looked for in four places, in this order: the GOG_CLI_PATH environment variable, the copy inside the installed package, gog-cli on your PATH, and a local target/release build. The first three are how an installed copy works; the last is how this page was rendered.