Doing Economics in Julia
  • Home
  • Setup
  • R → Julia
  1. Reference
  2. R → Julia
  • Getting started
    • Overview
    • Setup
  • Empirical projects
    • 1. Measuring climate change
    • 2. Data from experiments
    • 3. Measuring a sugar tax
    • 4. Measuring wellbeing
    • 5. Measuring inequality
    • 6. Management practices
    • 7. Supply and demand
    • 8. Cost of unemployment
    • 9. Credit-excluded households
    • 10. Banking systems
    • 11. Willingness to pay
    • 12. Hong Kong cash handout
    • Extra 1. Female labour supply
    • Extra 2. Carbon taxation
  • Reference
    • R → Julia
    • Technical reference
    • Source notes and known differences

On this page

  • Reading data
  • Subsetting and reshaping
  • Summary statistics
  • Time-series filters
  • Charts
    • The dual-axis chart
  • Soft scope: the one that actually bites
    • let is fix #1 only when you initialise inside it
  • Long tables lose their middle
  • Missing values
  • Numbers in the data
  • View source
  • Report an issue
  1. Reference
  2. R → Julia

R → Julia

Translating the book’s walk-throughs

Use this page for the Julia equivalents of the book’s R idioms. Project-specific exceptions are linked from the relevant walkthroughs.

Reading data

R Julia
read.csv("f.csv") CSV.read("f.csv", DataFrame)
read.csv("f.csv", skip = 1) CSV.read("f.csv", DataFrame; header = 2)
read.csv("f.csv", na.strings = "***") CSV.read("f.csv", DataFrame; missingstring = "***")
read_excel("f.xlsx") XLSX.readtable("f.xlsx", "Sheet1") \|> DataFrame
setwd("...") not needed — rawpath() resolves from the repo root
head(df) first(df, 6)
str(df) describe(df)
nrow(df) / ncol(df) nrow(df) / ncol(df)
names(df)[1] <- "Year" rename!(df, 1 => :Year)

skip = 1 becomes header = 2, not skipto. R counts lines to throw away; CSV.jl names the line the header is on. Off-by-one here silently turns your header row into data.

Subsetting and reshaping

R Julia
df[df$Month == 6, ] subset(df, :Month => ByRow(==(6)))
subset(df, Year >= 1951 & Year <= 1980) subset(df, :Year => ByRow(y -> 1951 <= y <= 1980))
df[, c("Jun", "Jul", "Aug")] df[:, [:Jun, :Jul, :Aug]]
unlist(df[, c("Jun", "Jul")]) vec(Matrix(df[:, [:Jun, :Jul]]))
merge(a, b) innerjoin(a, b; on = :Year)
spread(key = Sex, value = Value) unstack(df, :Sex, :Value)
reduce(full_join, frames) reduce((a, b) -> outerjoin(a, b; on = [:Country, :Year]), frames)
lag(x) / lead(x) vcat(missing, x[1:end-1]) / vcat(x[2:end], missing)
factor(x) categorical(x)
cut(x, breaks) cut(x, breaks) (CategoricalArrays.jl)
na.rm = TRUE skipmissing(x)

merge(a, b) with no by guesses the join key from shared column names. innerjoin makes you name it. The explicit version is worth the extra characters — a silent join on the wrong shared column is a hard bug to see.

Summary statistics

R Julia
mean(x) mean(x)
var(x) var(x)
sd(x) std(x)
quantile(x, c(0.3, 0.7)) quantile(x, [0.3, 0.7])
mean(x > 0.5) mean(x .> 0.5)
cor(x, y) cor(x, y)
mosaic::mean(y ~ g) combine(groupby(df, :g), :y => mean)
mosaic::var(y ~ g) combine(groupby(df, :g), :y => var)
table(x) freqtable(x) or countmap(x)

Two that agree where you might expect them not to:

  • var is the sample variance in both, dividing by n − 1. R has no option; Julia’s var(x; corrected = false) gives the population form. The book means the sample variance, so the default is right.
  • quantile uses the same default. R’s type = 7 and Julia’s default are the same linear-interpolation rule, so deciles match exactly. R offers nine types and Julia takes alpha/beta parameters; neither default needs touching here.

The mosaic package’s formula interface (mean(anomaly ~ period)) is the one idiom with no direct Julia counterpart. groupby + combine does the same job more verbosely and more explicitly — you name the output column, so a table of grouped statistics arrives already labelled.

Time-series filters

R Julia
mFilter::hpfilter(x, freq = 6.25) solve (I + λD′D) * trend = x, then cycle = x - trend

Project 13 implements the standard penalized least-squares definition directly: D is the second-difference matrix and λ is 6.25 for annual data. The helper checks that years are contiguous before filtering.

Charts

R Julia (Makie / AlgebraOfGraphics)
plot(x, y, type = "l") lines(x, y)
plot(x, y) scatter(x, y)
lines(x, y2) lines!(ax, x, y2)
abline(h = 0) hlines!(ax, 0)
abline(v = 1980) vlines!(ax, 1980)
text(x, y, "label") text!(ax, x, y; text = "label")
legend("topleft", ...) Legend(fig[1, 2], ax) or axislegend(ax)
hist(x, breaks = seq(...)) hist(x; bins = ...), or freqtable_binned + barplot
col =, lwd =, xlab = color =, linewidth =, xlabel =
mfrow = c(2, 2) fig[i, j] grid positions
ts(x, start = c(1880, 1), frequency = 12) keep Year/Month columns; build a Date

The ! suffix is the whole mental model: lines creates a new axis, lines! adds to an existing one. R’s plot / lines split does the same thing without saying so.

In Julia, keep Year and Month columns, construct a real date when needed, and let the axis follow from the data.

The dual-axis chart

Walk-through 1.8 plots temperature and CO₂ on one set of axes with two different y-scales, using par(new = TRUE) to draw the second series over the first and axis(side = 4) to give it its own scale on the right.

Project 1 replaces it with two panels: aligning two scales is arbitrary and can change the visual impression without changing any number.

Three alternatives:

  • Two stacked panels sharing an x-axis; each series keeps its own units.
  • Index both to a common base — index_to(x, 1) puts both at 100 in the first period — and plot on one axis. Requires both series to have a meaningful zero, which rules it out for anomaly data (see Project 1 Q3.4).
  • State the correlation coefficient, which is what the question asks for.

Project 1 uses the two-panel form and reports the correlation.

Soft scope: the one that actually bites

R’s for loop body shares the global environment, so this accumulates:

total <- 0
for (i in 1:3) total <- total + i   # total is 6

At top level, the direct Julia translation creates a new local instead:

total = 0
for i in 1:3
    total += i      # warns, and creates a NEW local each iteration
end
total               # still 0

Julia’s soft scope rule: a for, while or comprehension body at global scope treats assignment to an existing global as creating a local instead. It emits a warning, but a warning is easy to miss and the value is silently wrong — or the name is undefined when you read it back.

Three fixes, in order of preference:

# 1. Wrap in `let` - the counter becomes a real local, no warning
result = let
    total = 0
    for i in 1:3
        total += i
    end
    total
end

# 2. Build it functionally and skip the accumulator
total = sum(1:3)

# 3. Declare `global` - works, but a sign the code wants restructuring
total = 0
for i in 1:3
    global total += i
end

Inside a function this problem does not exist, because function bodies are hard scope. It only appears at top level — which is exactly where notebook and script code lives.

A trap specific to this repository: Quarto’s Julia engine wraps each cell in a way that makes the naive version work, so code that renders correctly on these pages can still fail when copied into a plain .jl script. Both Project 3 and Project 4 hit this. The pages use let blocks and up-front construction so that the code works either way.

let is fix #1 only when you initialise inside it

Fix #1 above works because total = 0 sits inside the let. Reach for let to rebind an outer name and it fails, which is the opposite trap:

df = load()
let keep = df.x .> 0
    df = df[keep, :]       # UndefVarError: `df` not defined in local scope
end

let is a hard scope: df = ... creates a local, so the right-hand side reads that local before it has a value. Julia raises an error, which is safer, but the message can make a plainly defined outer variable look missing.

Project 8 hit this while filtering four survey waves in sequence. The fix is a function that takes a frame and returns a new one:

function require_complete(df, cols; asked_in)
    keep = .!in.(df.wave, Ref(asked_in)) .| complete_in(df, cols)
    return df[keep, :]
end

clean = require_complete(clean, ["income"]; asked_in = ["2008-2010"])

The rule of thumb: let for a value you build and hand back, a function for a transformation you apply repeatedly. Neither needs global.

Long tables lose their middle

Quarto’s Julia engine displays each cell’s result through an IOContext carrying :limit => true. DataFrames honours that the way it would for a terminal: past about 26 rows it prints the head and tail and replaces everything between with a ⋮ row.

On a web page there is no reason to elide anything, and the failure is quiet — the table looks complete unless you count the rows. Project 8 shipped a 46-country table with 20 countries hidden, and the prose discussed three of the hidden ones.

show_all from DoingEconomics forwards the display with the limit off:

show_all(country_table)      # every row, instead of head + ⋮ + tail

Two details matter in its implementation. It defines show for exactly the MIME types DataFrames implements, not a generic show(::IO, ::MIME, ::AllRows) — Quarto picks an output format by attempting show rather than by consulting showable, so a catch-all method advertises text/markdown, gets chosen, and dies inside the forwarded call. And showable delegates to the wrapped table so the choice stays honest. test/runtests.jl pins both.

Missing values

R Julia
NA missing
is.na(x) ismissing(x)
mean(x, na.rm = TRUE) mean(skipmissing(x))
na.strings = "***" missingstring = "***"
complete.cases(df) dropmissing(df)

The difference that matters: R’s aggregate functions mostly default to propagating NA and offer na.rm to opt out; Julia has no such flag, and you wrap the input in skipmissing instead. Julia’s version is noisier to write and harder to get wrong by accident — you cannot drop missing data without a visible skipmissing at the call site.

Numbers in the data

The GISS temperature file writes values as -.37 rather than -0.37, and codes missing as ***. R’s read.csv handles the leading-dot form because as.numeric does; CSV.jl parses it too. Neither needs special handling, but it is worth knowing the file looks like that before concluding a parse has gone wrong.

Extra 2. Carbon taxation
Technical reference
Source Code
---
title: "R → Julia"
subtitle: "Translating the book's walk-throughs"
engine: markdown
---

Use this page for the Julia equivalents of the book's R idioms. Project-specific exceptions are linked from the relevant walkthroughs.

## Reading data

| R | Julia |
|---|---|
| `read.csv("f.csv")` | `CSV.read("f.csv", DataFrame)` |
| `read.csv("f.csv", skip = 1)` | `CSV.read("f.csv", DataFrame; header = 2)` |
| `read.csv("f.csv", na.strings = "***")` | `CSV.read("f.csv", DataFrame; missingstring = "***")` |
| `read_excel("f.xlsx")` | `XLSX.readtable("f.xlsx", "Sheet1") \|> DataFrame` |
| `setwd("...")` | not needed — `rawpath()` resolves from the repo root |
| `head(df)` | `first(df, 6)` |
| `str(df)` | `describe(df)` |
| `nrow(df)` / `ncol(df)` | `nrow(df)` / `ncol(df)` |
| `names(df)[1] <- "Year"` | `rename!(df, 1 => :Year)` |

`skip = 1` becomes `header = 2`, not `skipto`. R counts lines to throw away; CSV.jl names the
line the header is on. Off-by-one here silently turns your header row into data.

## Subsetting and reshaping

| R | Julia |
|---|---|
| `df[df$Month == 6, ]` | `subset(df, :Month => ByRow(==(6)))` |
| `subset(df, Year >= 1951 & Year <= 1980)` | `subset(df, :Year => ByRow(y -> 1951 <= y <= 1980))` |
| `df[, c("Jun", "Jul", "Aug")]` | `df[:, [:Jun, :Jul, :Aug]]` |
| `unlist(df[, c("Jun", "Jul")])` | `vec(Matrix(df[:, [:Jun, :Jul]]))` |
| `merge(a, b)` | `innerjoin(a, b; on = :Year)` |
| `spread(key = Sex, value = Value)` | `unstack(df, :Sex, :Value)` |
| `reduce(full_join, frames)` | `reduce((a, b) -> outerjoin(a, b; on = [:Country, :Year]), frames)` |
| `lag(x)` / `lead(x)` | `vcat(missing, x[1:end-1])` / `vcat(x[2:end], missing)` |
| `factor(x)` | `categorical(x)` |
| `cut(x, breaks)` | `cut(x, breaks)` (CategoricalArrays.jl) |
| `na.rm = TRUE` | `skipmissing(x)` |

`merge(a, b)` with no `by` guesses the join key from shared column names. `innerjoin` makes
you name it. The explicit version is worth the extra characters — a silent join on the wrong
shared column is a hard bug to see.

## Summary statistics

| R | Julia |
|---|---|
| `mean(x)` | `mean(x)` |
| `var(x)` | `var(x)` |
| `sd(x)` | `std(x)` |
| `quantile(x, c(0.3, 0.7))` | `quantile(x, [0.3, 0.7])` |
| `mean(x > 0.5)` | `mean(x .> 0.5)` |
| `cor(x, y)` | `cor(x, y)` |
| `mosaic::mean(y ~ g)` | `combine(groupby(df, :g), :y => mean)` |
| `mosaic::var(y ~ g)` | `combine(groupby(df, :g), :y => var)` |
| `table(x)` | `freqtable(x)` or `countmap(x)` |

Two that agree where you might expect them not to:

- **`var` is the sample variance in both**, dividing by `n − 1`. R has no option; Julia's
  `var(x; corrected = false)` gives the population form. The book means the sample variance,
  so the default is right.
- **`quantile` uses the same default.** R's `type = 7` and Julia's default are the same
  linear-interpolation rule, so deciles match exactly. R offers nine types and Julia takes
  `alpha`/`beta` parameters; neither default needs touching here.

The `mosaic` package's formula interface (`mean(anomaly ~ period)`) is the one idiom with no
direct Julia counterpart. `groupby` + `combine` does the same job more verbosely and more
explicitly — you name the output column, so a table of grouped statistics arrives already
labelled.

## Time-series filters

| R | Julia |
|---|---|
| `mFilter::hpfilter(x, freq = 6.25)` | solve `(I + λD′D) * trend = x`, then `cycle = x - trend` |

Project 13 implements the standard penalized least-squares definition directly: `D` is the
second-difference matrix and λ is 6.25 for annual data. The helper checks that years are
contiguous before filtering.

## Charts

| R | Julia (Makie / AlgebraOfGraphics) |
|---|---|
| `plot(x, y, type = "l")` | `lines(x, y)` |
| `plot(x, y)` | `scatter(x, y)` |
| `lines(x, y2)` | `lines!(ax, x, y2)` |
| `abline(h = 0)` | `hlines!(ax, 0)` |
| `abline(v = 1980)` | `vlines!(ax, 1980)` |
| `text(x, y, "label")` | `text!(ax, x, y; text = "label")` |
| `legend("topleft", ...)` | `Legend(fig[1, 2], ax)` or `axislegend(ax)` |
| `hist(x, breaks = seq(...))` | `hist(x; bins = ...)`, or `freqtable_binned` + `barplot` |
| `col = `, `lwd = `, `xlab = ` | `color = `, `linewidth = `, `xlabel = ` |
| `mfrow = c(2, 2)` | `fig[i, j]` grid positions |
| `ts(x, start = c(1880, 1), frequency = 12)` | keep `Year`/`Month` columns; build a `Date` |

The `!` suffix is the whole mental model: `lines` creates a new axis, `lines!` adds to an
existing one. R's `plot` / `lines` split does the same thing without saying so.

In Julia, keep `Year` and `Month` columns, construct a real date when needed, and let the axis follow from the data.

### The dual-axis chart

Walk-through 1.8 plots temperature and CO₂ on one set of axes with two different y-scales,
using `par(new = TRUE)` to draw the second series over the first and `axis(side = 4)` to give
it its own scale on the right.

Project 1 replaces it with two panels: aligning two scales is arbitrary and can change the
visual impression without changing any number.

Three alternatives:

- **Two stacked panels** sharing an x-axis; each series keeps its own units.
- **Index both to a common base** — `index_to(x, 1)` puts both at 100 in the first period —
  and plot on one axis. Requires both series to have a meaningful zero, which rules it out for
  anomaly data (see Project 1 Q3.4).
- **State the correlation coefficient**, which is what the question asks for.

Project 1 uses the two-panel form and reports the correlation.

## Soft scope: the one that actually bites

R's `for` loop body shares the global environment, so this accumulates:

```r
total <- 0
for (i in 1:3) total <- total + i   # total is 6
```

At top level, the direct Julia translation creates a new local instead:

```julia
total = 0
for i in 1:3
    total += i      # warns, and creates a NEW local each iteration
end
total               # still 0
```

Julia's **soft scope** rule: a `for`, `while` or comprehension body at global scope treats
assignment to an existing global as creating a local instead. It emits a warning, but a warning
is easy to miss and the value is silently wrong — or the name is undefined when you read it back.

Three fixes, in order of preference:

```julia
# 1. Wrap in `let` - the counter becomes a real local, no warning
result = let
    total = 0
    for i in 1:3
        total += i
    end
    total
end

# 2. Build it functionally and skip the accumulator
total = sum(1:3)

# 3. Declare `global` - works, but a sign the code wants restructuring
total = 0
for i in 1:3
    global total += i
end
```

Inside a function this problem does not exist, because function bodies are hard scope. It only
appears at top level — which is exactly where notebook and script code lives.

**A trap specific to this repository:** Quarto's Julia engine wraps each cell in a way that makes
the naive version work, so code that renders correctly on these pages can still fail when copied
into a plain `.jl` script. Both Project 3 and Project 4 hit this. The pages use `let` blocks and
up-front construction so that the code works either way.

### `let` is fix #1 only when you initialise inside it

Fix #1 above works because `total = 0` sits *inside* the `let`. Reach for `let` to **rebind an
outer name** and it fails, which is the opposite trap:

```julia
df = load()
let keep = df.x .> 0
    df = df[keep, :]       # UndefVarError: `df` not defined in local scope
end
```

`let` is a hard scope: `df = ...` creates a local, so the right-hand side reads that local before it has a value. Julia raises an error, which is safer, but the message can make a plainly defined outer variable look missing.

Project 8 hit this while filtering four survey waves in sequence. The fix is a function that takes a frame and returns a new one:
```julia
function require_complete(df, cols; asked_in)
    keep = .!in.(df.wave, Ref(asked_in)) .| complete_in(df, cols)
    return df[keep, :]
end

clean = require_complete(clean, ["income"]; asked_in = ["2008-2010"])
```

The rule of thumb: `let` for a value you build and hand back, a function for a transformation
you apply repeatedly. Neither needs `global`.

## Long tables lose their middle

Quarto's Julia engine displays each cell's result through an `IOContext` carrying
`:limit => true`. DataFrames honours that the way it would for a terminal: past about 26 rows it
prints the head and tail and replaces everything between with a `⋮` row.

On a web page there is no reason to elide anything, and the failure is quiet — the table looks
complete unless you count the rows. Project 8 shipped a 46-country table with 20 countries
hidden, and the prose discussed three of the hidden ones.

`show_all` from `DoingEconomics` forwards the display with the limit off:

```julia
show_all(country_table)      # every row, instead of head + ⋮ + tail
```

Two details matter in its implementation. It defines `show` for exactly the MIME types
DataFrames implements, **not** a generic `show(::IO, ::MIME, ::AllRows)` — Quarto picks an output
format by attempting `show` rather than by consulting `showable`, so a catch-all method
advertises `text/markdown`, gets chosen, and dies inside the forwarded call. And `showable`
delegates to the wrapped table so the choice stays honest. `test/runtests.jl` pins both.

## Missing values

| R | Julia |
|---|---|
| `NA` | `missing` |
| `is.na(x)` | `ismissing(x)` |
| `mean(x, na.rm = TRUE)` | `mean(skipmissing(x))` |
| `na.strings = "***"` | `missingstring = "***"` |
| `complete.cases(df)` | `dropmissing(df)` |

The difference that matters: R's aggregate functions mostly default to *propagating* `NA` and
offer `na.rm` to opt out; Julia has no such flag, and you wrap the input in `skipmissing`
instead. Julia's version is noisier to write and harder to get wrong by accident — you cannot
drop missing data without a visible `skipmissing` at the call site.

## Numbers in the data

The GISS temperature file writes values as `-.37` rather than `-0.37`, and codes missing as
`***`. R's `read.csv` handles the leading-dot form because `as.numeric` does; CSV.jl parses it
too. Neither needs special handling, but it is worth knowing the file looks like that before
concluding a parse has gone wrong.

Code MIT-licensed. Projects and solutions © CORE Econ.

 
  • View source
  • Report an issue

Built with Quarto and Julia.