Basics
Hello, World
cat("Hello, World!\n") println("Hello, World!") Julia’s
println adds the newline cat makes you write, and — like cat, unlike R’s print — it never prefixes output with [1]. There is also plain print, which omits the newline.<- becomes =
sample_size <- 30
mean_height = 170 # = also works, but style guides prefer <-
cat(sample_size, mean_height, "\n") sample_size = 30
mean_height = 170 # = is the only assignment operator
println(sample_size, " ", mean_height)
μ = 170 # any Unicode identifier — type \mu then TAB
println(μ) Julia has one assignment operator,
=, with no arrow and no right-assign. The consolation prize is real Unicode identifiers: μ, σ, and θ are legal variable names (typed as LaTeX abbreviations plus TAB in the REPL and editors), so statistical code can read like the paper it implements.TRUE/FALSE → true/false
is_ready <- TRUE
is_done <- F # the T/F shorthand works (and is a classic footgun)
print(is_ready)
if (is_ready) cat("go\n")
print(sum(c(TRUE, FALSE, TRUE))) # logicals count as 1 and 0 is_ready = true
is_done = false # no T/F shorthand — and none of its dangers
println(is_ready)
if is_ready
println("go")
end
println(sum([true, false, true])) # Bool counts as 1 and 0 here too The capitals go away:
true/false, with no overwritable T/F shorthand. Blocks are delimited by end rather than braces, and the condition needs no parentheses. One habit transfers intact: summing a logical vector to count matches works in both languages, because Julia’s Bool is an integer type just as R’s logicals are.The Comfort Zone
Indexing is still 1-based
heights <- c(160, 172, 181)
print(heights[1]) # the first element
print(heights[length(heights)]) # the last heights = [160, 172, 181]
println(heights[1]) # the first element — no zero-shift to learn
println(heights[end]) # the last: end means "the final index" Unlike nearly every other language an R user might move to, Julia counts from one —
heights[1] means what it has always meant. The bonus is end: a keyword valid inside any indexing bracket, so heights[end] replaces R’s heights[length(heights)] dance, and heights[end - 1] reaches the second-to-last.1:5 still has five elements
print(1:5) # 1 2 3 4 5 — inclusive
for (trial in 1:3) {
cat("trial", trial, "\n")
}
print(seq(0, 10, by = 2))
print(seq(0, 1, length.out = 5)) println(collect(1:5)) # 1..5 — inclusive, same as R
for trial in 1:3
println("trial ", trial)
end
println(collect(0:2:10)) # start:STEP:stop — the step sits in the MIDDLE
println(collect(range(0, 1, length = 5))) Ranges include both ends, exactly like R’s. The one syntax trap: a stepped range is
start:step:stop — the step goes in the middle, so R’s seq(0, 10, by = 2) is 0:2:10, not 0:10:2. Ranges are lazy (hence collect to print their elements), and range(0, 1, length = 5) plays length.out.Negative Indexing Is Gone
🚨 x[-1] is an error, not a selection
heights <- c(160, 172, 181)
print(heights[-1]) # drops the first element: 172 181
print(heights[-c(1, 2)]) # drops the first two: 181 heights = [160, 172, 181]
println(heights[2:end]) # drop the first — R's heights[-1]
println(heights[3:end]) # drop the first two
# heights[-1] is a BoundsError — negative indices simply do not exist Julia refuses the negative-index question rather than answering it differently:
heights[-1] throws a BoundsError instead of silently meaning something new (as it does in Python). Exclusion is spelled with ranges off end, or with a boolean mask for scattered positions. An error is the kindest possible translation of R’s most habit-forming idiom.Logical masks still work (with a dot)
doses <- c(10, 20, 30)
print(doses > 15) # a logical vector
print(doses[doses > 15]) # mask indexing doses = [10, 20, 30]
println(doses .> 15) # a BitVector — Julia's logical vector
println(doses[doses .> 15]) # mask indexing, exactly as in R The single most-used R idiom survives intact: index a vector with a boolean mask and keep the
true positions. The only change is the dot on the comparison (.>), the subject of the next section — the mask itself, and indexing with it, behave precisely as R trained you to expect.Vectorization Becomes Explicit
The dot is R’s invisible vectorization, spelled out
doses <- c(1, 2, 3)
print(doses * 2)
print(doses + 10)
print(sqrt(doses)) doses = [1, 2, 3]
println(doses .* 2) # dotted operators broadcast elementwise
println(doses .+ 10)
println(sqrt.(doses)) # a dot after ANY function broadcasts it
# doses + 10 (no dot) is an error — broadcasting is always explicit What R does invisibly, Julia does explicitly:
.*, .+, and function.(arguments) mean “apply elementwise.” The payoff for the extra character is generality — every function broadcasts, not just the ones written to, and chained dotted operations fuse into a single loop with no intermediate vectors. (A few linear-algebra operators like scalar * work undotted, but the dot is the reliable habit.)Your own functions broadcast too
celsius_to_fahrenheit <- function(celsius) celsius * 9 / 5 + 32
temperatures <- c(0, 20, 37)
print(celsius_to_fahrenheit(temperatures)) # vectorizes for free celsius_to_fahrenheit(celsius) = celsius * 9 / 5 + 32
temperatures = [0, 20, 37]
println(celsius_to_fahrenheit.(temperatures)) # broadcast with the dot
println(@. temperatures * 9 / 5 + 32) # @. dots a whole expression The R function vectorizes for free only because every operation inside it happens to vectorize; write scalar logic (an
if, say) and the free ride ends. The Julia function is honestly scalar, and the call site chooses to broadcast it — over any function, any logic. The @. macro dots every call in an expression when writing each dot gets tedious.🚨 Recycling is gone
print(c(1, 2, 3, 4) + c(10, 20)) # silent recycling: 11 22 13 24
print(c(1, 2, 3) + c(10, 20)) # recycles with a warning: 11 22 13 println([1, 2, 3, 4] .+ 10) # scalars broadcast against anything
# [1, 2, 3, 4] .+ [10, 20] is a DimensionMismatch ERROR —
# mismatched lengths never recycle, silently or otherwise R’s recycling — the source of a whole genre of silent bugs — does not exist. A scalar broadcasts against any shape, and arrays of compatible shapes (equal, or length-1 along a dimension) broadcast together; anything else is an immediate
DimensionMismatch error rather than a quietly wrong answer.Comprehensions, when a dot is not enough
doses <- c(10, 20, 30)
print(sapply(doses, \(dose) dose / 10))
print(doses[doses > 15]) doses = [10, 20, 30]
println([dose / 10 for dose in doses])
println([dose for dose in doses if dose > 15]) # filter built in Julia also has Python-style comprehensions —
[expression for item in items if condition] — useful when the transformation involves a condition or builds something a broadcast cannot. Between dots, comprehensions, and fast plain loops, sapply/vapply gymnastics have three idiomatic replacements.Loops Are Fast Now
The vectorize-or-suffer culture ends here
sum_of_squares <- function(limit) {
total <- 0
for (value in 1:limit) {
total <- total + value^2
}
total
}
# Works, but every R styleguide says: never loop over a million elements
print(sum_of_squares(1000000)) function sum_of_squares(limit)
total = 0.0
for value in 1:limit
total += value^2
end
return total
end
# Compiles to machine code on first call — loops are as fast as C
println(sum_of_squares(1_000_000)) This is the reason Julia exists. R’s interpreter makes scalar loops so slow that vectorization is survival, not style; Julia’s JIT compiles each function to native code, so a plain loop runs at C speed and “how do I vectorize this?” stops being a prerequisite to getting work done. Put hot code in functions — the function is the unit of compilation. (Note the digit separator:
1_000_000 is legal Julia.)Copies, Aliases & the ! Convention
🚨 Assignment aliases
original <- c(1, 2, 3)
copied <- original # R copies on modify —
copied[1] <- 99 # this touches only the copy
print(original) # 1 2 3, untouched original = [1, 2, 3]
aliased = original # NOT a copy — the same array
aliased[1] = 99
println(original) # [99, 2, 3] — changed through the alias!
independent = copy(original)
independent[1] = 1
println(original) # unaffected by the real copy Copy-on-modify does not survive the move: Julia assignment binds a second name to the same array, and mutation shows through every alias.
copy (or deepcopy for nested structures) restores R’s semantics explicitly. The next row shows the convention that makes this far less dangerous in practice than it sounds.The ! suffix flags every mutating function
values <- c(3, 1, 2)
print(sort(values)) # returns a sorted copy
print(values) # original untouched — always
values <- c(values, 4) # "growing" means copy-and-reassign
print(values) values = [3, 1, 2]
println(sort(values)) # no ! — returns a sorted copy, like R
println(values) # untouched
sort!(values) # the ! warns: mutates in place
println(values)
push!(values, 4) # true in-place growth — no copy made
println(values) Julia’s naming convention does what R’s semantics did:
sort returns a copy exactly as R’s does, and the mutating variant carries a ! in its name — sort!, push!, filter! — so every function that can change your data announces it at the call site. In-place push! also ends the quiet quadratic cost of growing a vector by c(values, new) in a loop.Functions
One-liners, and the last expression still returns
standardize <- function(values) {
(values - mean(values)) / sd(values) # last expression is returned
}
print(standardize(c(10, 20, 30))) using Statistics
standardize(values) = (values .- mean(values)) ./ std(values)
println(standardize([10, 20, 30])) Two comforts transfer: a function’s last expression is its return value (no mandatory
return, though it exists), and short functions get the assignment form name(arguments) = expression — even terser than R’s. Note the dots on .- and ./, and that mean/std need using Statistics (the subject of a later section).\(x) → x -> …
double <- \(value) value * 2 # the 4.1+ lambda shorthand
print(double(21))
print(sapply(c(1, 2, 3), \(value) value ^ 2)) double = value -> value * 2
println(double(21))
println(map(value -> value^2, [1, 2, 3])) R’s
\(x) becomes the arrow x -> expression (with (x, y) -> … for two arguments), and map plays sapply — though in Julia the broadcast dot (value -> value^2).([1, 2, 3]) or a comprehension is usually the more idiomatic spelling. Exponentiation stays ^, exactly as in R.🚨 Named arguments need a declared slot
rescale <- function(values, center = 0, scale = 1) {
(values - center) / scale
}
print(rescale(c(10, 20, 30), center = 20))
print(rescale(center = 20, c(10, 20, 30))) # names match ANY argument,
# in any position function rescale(values; center = 0, scale = 1)
(values .- center) ./ scale
end
println(rescale([10, 20, 30], center = 20))
# rescale(20, [10, 20, 30]) would NOT match by name — positional
# arguments are positional only; keywords live after the ; in the signature In R, any argument may be passed by name from any position. Julia splits the two worlds with a semicolon in the signature: arguments before
; are positional only, arguments after it are keyword only. Calling rescale(values, center = 20) reads the same as R, but the reordering tricks R allows are a MethodError — the signature, not the call, decides what may be named.... → args... (slurp and splat)
report <- function(label, ...) {
extras <- list(...)
cat(label, ":", length(extras), "extra values\n")
}
report("run", 1, 2, 3)
do.call(report, c(list("splat"), as.list(4:6))) function report(label, values...)
println(label, ": ", length(values), " extra values")
end
report("run", 1, 2, 3)
numbers = [4, 5, 6]
report("splat", numbers...) # ... splats a collection into arguments R’s
... becomes a named slurp — values... collects the extra arguments into a tuple you can index and iterate directly, with no list(...) unwrapping. The same three dots on the calling side splat a collection into individual arguments, replacing the entire do.call ritual.S3 → Multiple Dispatch
S3 generics, grown up
circle <- structure(list(radius = 1), class = "circle")
rectangle <- structure(list(width = 2, height = 3), class = "rectangle")
area <- function(shape) UseMethod("area")
area.circle <- function(shape) pi * shape$radius^2
area.rectangle <- function(shape) shape$width * shape$height
print(area(circle))
print(area(rectangle)) struct Circle
radius::Float64
end
struct Rectangle
width::Float64
height::Float64
end
area(shape::Circle) = pi * shape.radius^2
area(shape::Rectangle) = shape.width * shape.height
println(area(Circle(1.0)))
println(area(Rectangle(2.0, 3.0))) The S3 mental model — generic functions choosing an implementation by class, behavior living outside the data — is exactly Julia’s model, made rigorous. Methods are declared with type annotations instead of the
generic.class naming pun, and dispatch consults every argument’s type, not just the first (what S4 calls multiple dispatch is the default here). This, not objects with methods, is the organizing idea of the whole language.list + class() → struct
patient <- list(name = "Ada", age = 36)
class(patient) <- "patient"
print(patient$name)
patient$age <- 37 # lists are freely mutable and freely extensible
patient$new_field <- TRUE
print(patient$age) struct Patient
name::String
age::Int
end
ada = Patient("Ada", 36)
println(ada.name)
# ada.age = 37 is an error — structs are immutable by default;
# use "mutable struct" to opt in, and fields are fixed either way R’s tagged list becomes a real record: fields are declared, typed, and accessed with
. instead of $. Structs are immutable by default (mutable struct opts out) and their field set is fixed at definition — no bolting on new_field at runtime. The type declarations are also what multiple dispatch selects on, so the definition does double duty.NA → missing
NA → missing (a real one!)
reading <- NA
print(is.na(reading))
print(NA + 1) # NA propagates
print(NA == NA) # NA — three-valued logic
print(identical(NA, NA)) # TRUE, when you need a decision reading = missing
println(ismissing(reading))
println(missing + 1) # missing propagates, exactly like NA
println(missing == missing) # missing — the same three-valued logic
println(missing === missing) # true, when you need a decision Unlike nearly every general-purpose language, Julia has a true
NA: missing is a first-class value that propagates through arithmetic and comparisons with R’s exact three-valued logic. An R user’s entire missing-data intuition transfers unchanged — the rarest sentence on this site.na.rm → skipmissing
values <- c(1, 2, NA, 4)
print(mean(values)) # NA — you must opt out
print(mean(values, na.rm = TRUE))
values[is.na(values)] <- 0 # replace NAs
print(values) using Statistics
values = [1, 2, missing, 4]
println(mean(values)) # missing — propagates, like R
println(mean(skipmissing(values))) # na.rm = TRUE
println(coalesce.(values, 0)) # replace missings Where R threads
na.rm = TRUE through each function’s arguments, Julia wraps the data once: skipmissing(values) is a lazy view any aggregation accepts. Propagation stays the conservative default, matching R rather than pandas. Broadcast coalesce.(values, 0) plays the replace-NA idiom without mutating the original.NULL → nothing (still distinct from missing)
absent <- NULL # no value at all
unknown <- NA # a value we do not know
print(is.null(absent))
print(is.na(unknown))
print(length(c(1, NULL, 3))) # NULL vanishes in c() — 2 elements
print(length(c(1, NA, 3))) # NA is a real element — 3 absent = nothing # R's NULL — no value at all
unknown = missing # R's NA — a value we do not know
println(isnothing(absent))
println(ismissing(unknown))
println(length([1, missing, 3])) # missing is a real element — 3 R’s two flavors of absence both survive:
nothing is NULL (software absence — a function with no result), missing is NA (statistical absence — an unmeasured value). Julia keeps them apart for the same reason R does, and the distinction matters to the same degree: mix them up and aggregations either error or silently narrow.Statistics Move to a Module
mean and sd are no longer free
values <- c(2, 4, 4, 4, 5, 5, 7, 9)
print(mean(values)) # statistics are built into base R
print(median(values))
print(sd(values))
print(quantile(values, 0.25)) using Statistics # ships with Julia, but must be loaded
values = [2, 4, 4, 4, 5, 5, 7, 9]
println(mean(values))
println(median(values))
println(std(values)) # sd is spelled std
println(quantile(values, 0.25)) R is a statistics environment with a language attached; Julia is a language with statistics in a module.
using Statistics loads the standard library (shipped with every Julia, no installation) that provides mean, median, std, var, quantile, and cor — and its quantile even defaults to the same type-7 definition R’s does. Real modeling (distributions, regressions, tests) lives in packages, as the ecosystem section maps out.Strings
paste & sprintf → $ interpolation
name <- "Ada"
trials <- 3
cat("Subject", name, "completed", trials, "trials\n")
cat(sprintf("%s: %.1f%% done\n", name, 66.67)) name = "Ada"
trials = 3
println("Subject $name completed $trials trials")
println("Total: $(trials * 10) planned") # $( ) for expressions
using Printf
@printf("%s: %.1f%% done\n", name, 66.67) Interpolation happens inside the string itself:
$name splices a variable and $(expression) splices anything, retiring most paste calls. The sprintf muscle memory maps to @printf from the Printf standard library — same format specifiers, same behavior, loaded with one using.String functions stay functions
phrase <- "stitch in time"
print(toupper(phrase))
print(nchar(phrase))
print(strsplit(phrase, " ")[[1]])
print(sub("time", "space", phrase))
print(grepl("time", phrase)) phrase = "stitch in time"
println(uppercase(phrase))
println(length(phrase))
println(split(phrase, " "))
println(replace(phrase, "time" => "space"))
println(occursin("time", phrase)) Good news for R muscle memory: strings keep function-call style (no
phrase.upper() methods to learn) — the work is mostly renames. strsplit’s [[1]] dance disappears because split returns a plain vector, and replace takes an old => new pair. Note the argument order flip from sub(pattern, replacement, x): the string comes first in Julia.Matrices & Linear Algebra
matrix() → a literal syntax
counts <- matrix(1:4, nrow = 2) # fills column-major
print(counts)
print(dim(counts))
print(t(counts)) counts = [1 3; 2 4] # spaces separate columns, ; separates rows
println(counts)
println(size(counts))
println(counts') # ' is transpose, replacing t() Matrices get literal syntax —
[1 3; 2 4] lays out rows visually — and a postfix ’ for transpose. Underneath, the two languages agree on the important invisible thing: storage is column-major in both (Julia chose it deliberately, for the same BLAS/LAPACK compatibility R inherited), so loop-over-columns performance instincts transfer intact.🚨 * and %*% swap jobs
scaling <- matrix(c(2, 0, 0, 3), nrow = 2)
points <- matrix(c(1, 1, 2, 2), nrow = 2)
print(scaling * points) # * is ELEMENTWISE in R
print(scaling %*% points) # %*% is matrix multiplication scaling = [2 0; 0 3]
points = [1 2; 1 2]
println(scaling * points) # * IS matrix multiplication in Julia
println(scaling .* points) # elementwise needs the dot The trap runs in both directions: R’s bare
* on matrices is elementwise and %*% multiplies; Julia’s bare * is matrix multiplication (linear-algebra convention) and elementwise needs .*. Port a formula without translating the operators and both languages will happily compute the wrong thing without a word of complaint — this row is worth memorizing outright.Error Handling
tryCatch → try/catch (as an expression)
result <- tryCatch({
stop("model failed to converge")
}, error = function(condition) {
cat("caught:", conditionMessage(condition), "\n")
NA
}, finally = {
cat("cleanup\n")
})
print(result) result = try
error("model failed to converge")
catch caught
println("caught: ", caught.msg)
missing
finally
println("cleanup")
end
println(result) The shape translates almost line for line — and, pleasantly for an R user, Julia’s
try is an expression whose value is the last line of whichever branch ran, so the assign-the-fallback pattern works without a handler function. stop becomes error, the handler is a block naming the caught exception, and finally transfers by name.Pipes
|> exists here too (with a twist)
measurements <- c(12, 7, 25, 31, 8)
measurements |> sort() |> head(3) |> print()
# R's |> splices the value into the FIRST ARGUMENT slot measurements = [12, 7, 25, 31, 8]
measurements |> sort |> (values -> first(values, 3)) |> println
# Julia's |> applies a UNARY function — extra arguments
# need an anonymous wrapper (or the ecosystem's @chain macro) The pipe operator looks identical and differs in one mechanical detail: R’s
|> rewrites the call to put the value in the first argument slot (so head(3) works), while Julia’s applies a one-argument function (so sort is bare — no parentheses — and multi-argument steps need value -> …). The tidyverse-style experience lives in packages: Chain.jl’s @chain reads almost exactly like magrittr.The Ecosystem Map
The ecosystem map
# The tidyverse / base-R toolbox...
library(dplyr) # data manipulation
library(ggplot2) # plotting
library(readr) # data import
library(lme4) # mixed models
# install.packages("dplyr") # ...and its Julia counterparts:
using DataFrames # data frames (with Chain.jl for the dplyr feel)
using CSV # data import
using Plots # plotting (AlgebraOfGraphics.jl for the ggplot feel)
using Distributions # d/p/q/r-style distribution functions
using GLM, MixedModels # lm/glm and lme4 territory
# using Pkg; Pkg.add("DataFrames") (or ] add DataFrames in the REPL) The rough map:
DataFrames.jl plus Chain.jl covers dplyr, CSV.jl covers readr, Plots.jl is workhorse plotting with AlgebraOfGraphics.jl as the grammar-of-graphics heir, Distributions.jl replaces the dnorm/pnorm/qnorm/rnorm family with distribution objects, and GLM.jl/MixedModels.jl cover lm/glm/lme4 — MixedModels.jl is maintained by lme4’s own author. install.packages becomes Pkg.add. Both cells are import inventories, shown display-only.