Basics
Hello, World
cat("Hello, World!\n") package main
import "fmt"
func main() {
fmt.Println("Hello, World!")
} Every Go program needs a
package main declaration and a func main() entry point — there is no bare top-level statement the way R’s script files allow. fmt.Println adds the newline cat makes you write.<- becomes := (and types get involved)
sample_size <- 30
mean_height <- 170.5
cat(sample_size, mean_height, "\n") package main
import "fmt"
func main() {
sampleSize := 30 // := declares AND assigns; type is inferred
var meanHeight float64 // var declares with an explicit type
meanHeight = 170.5
fmt.Println(sampleSize, meanHeight)
} := is the everyday assignment — it declares a new variable and infers its type in one step, the closest cognitive match to R’s <-. var name Type is the explicit form, used when a variable needs a specific type or no initial value. Note camelCase, not snake_case.Compiled, Not Interactive
The console habit stops working
# R runs each line as you write it — no separate "compile" step
values <- c(1, 2, 3)
print(sum(values))
# a typo shows up only when that line executes, and only THEN:
tryCatch(print(sumx(values)), error = \(condition) {
cat("runtime error:", conditionMessage(condition), "\n")
}) package main
import "fmt"
func main() {
values := []int{1, 2, 3}
total := 0
for _, value := range values {
total += value
}
fmt.Println(total)
// a typo like sumx(values) is a COMPILE error —
// the program never starts running at all
} There is no line-by-line REPL exploration as the primary workflow: the whole file is checked and compiled before anything runs, so a typo anywhere is caught before the first
fmt.Println fires — not partway through a long analysis (the R column wraps the typo in tryCatch only so this comparison keeps running; unguarded, that line simply halts the script). Go does have a REPL-like go run for scripts and a real interactive shell exists via third-party tools, but the compile-first mental model is the one to adopt.if is a statement, not an expression
sample_size <- 12
label <- if (sample_size >= 30) "large" else "small"
print(label) package main
import "fmt"
func main() {
sampleSize := 12
var label string
if sampleSize >= 30 {
label = "large"
} else {
label = "small"
}
fmt.Println(label)
} Unlike R (and Scala, Julia, and Ruby), Go’s
if has no value — it cannot be assigned. The R idiom of “assign the result of a conditional” becomes declare-then-branch-then-set. Conditions also need no parentheses, but the braces are mandatory, even for a single statement.Everything Is Statically Typed
🚨 A variable has one type, forever
measurement <- 5
print(class(measurement))
measurement <- "five" # R happily lets a variable change type
print(class(measurement)) package main
import "fmt"
func main() {
measurement := 5
fmt.Printf("%T\n", measurement)
// measurement = "five" is a COMPILE ERROR:
// cannot use "five" (untyped string constant) as int value
} R lets any name hold any type at any time; Go fixes a variable’s type the moment it is declared (inferred from the first value with
:=), and no later assignment may change it. This is the single biggest mental shift on the page — every value in the program has one, fixed, known type, checked before the program ever runs.Implicit coercion is gone
print(1 + TRUE) # TRUE coerces to 1 — silently
print(paste("count:", 5)) # 5 coerces to "5" — silently
print(as.character(5))
print(as.integer("5")) package main
import (
"fmt"
"strconv"
)
func main() {
// 1 + true does not compile — no automatic bool-to-int coercion
fmt.Println("count: " + strconv.Itoa(5)) // explicit int-to-string
parsed, _ := strconv.Atoi("5") // explicit string-to-int
fmt.Println(parsed)
} R quietly converts between types whenever an operation needs it — booleans become numbers, numbers become strings inside
paste. Go converts nothing automatically; every conversion is a named function call (strconv.Itoa, strconv.Atoi), and mixing types without one is a compile error. The upside: the conversion is always visible in the code, never a silent surprise buried in output.No Vectorization At All
There is no library that vectorizes this
doses <- c(1, 2, 3)
print(doses * 2) # elementwise, invisibly
print(sqrt(doses))
print(sum(doses)) package main
import (
"fmt"
"math"
)
func main() {
doses := []float64{1, 2, 3}
doubled := make([]float64, len(doses))
roots := make([]float64, len(doses))
total := 0.0
for index, dose := range doses {
doubled[index] = dose * 2
roots[index] = math.Sqrt(dose)
total += dose
}
fmt.Println(doubled)
fmt.Println(roots)
fmt.Println(total)
} This is the sharpest culture shock for an R user: Python has numpy, Julia has the dot, Scala has
map — Go has none of these. A for/range loop is the ONLY way to touch every element of a slice; there is no elementwise-arithmetic library, standard or third-party, that Go idiom reaches for. The consolation: loops compile to genuinely fast native code, so the loop itself is never the bottleneck R trained you to fear.Vectors → Slices
c() → a slice literal
heights <- c(160, 172, 181)
print(heights[1]) # the first element
print(heights[length(heights)]) # the last
print(length(heights)) package main
import "fmt"
func main() {
heights := []int{160, 172, 181}
fmt.Println(heights[0]) // zero-based
fmt.Println(heights[len(heights)-1]) // the last
fmt.Println(len(heights))
} A slice literal,
[]int{...}, replaces c(...) — the type of every element must be the same, and stated once, up front. Indexing is zero-based, and there is no end-style keyword: the last element is always slice[len(slice)-1].Growing a slice: append, not c()
readings <- c(1, 2, 3)
readings <- c(readings, 4) # rebuild the whole vector
print(readings)
subset <- readings[2:3] # inclusive, 1-based
print(subset) package main
import "fmt"
func main() {
readings := []int{1, 2, 3}
readings = append(readings, 4) // grows (and may reallocate)
fmt.Println(readings)
subset := readings[1:3] // half-open, 0-based: elements 2,3
fmt.Println(subset)
} append plays c(readings, 4) — note it returns a (possibly new) slice that must be reassigned, since the underlying array may need to grow. Slicing keeps R’s bracket syntax but flips to half-open, zero-based bounds: R’s 2:3 (inclusive, 1-based) becomes [1:3] (exclusive end, 0-based start).Named Lists → Maps
Named lists → map[K]V
readings <- list(monday = 3.1, tuesday = 2.7)
print(readings$monday)
readings$wednesday <- 4.0
print(readings) package main
import "fmt"
func main() {
readings := map[string]float64{"monday": 3.1, "tuesday": 2.7}
fmt.Println(readings["monday"])
readings["wednesday"] = 4.0
fmt.Println(readings)
} A named list becomes a
map[KeyType]ValueType — but unlike R’s list, every key must be the same type and every value must be the same type, declared up front. Access uses brackets, not $, and a lookup by key or an assignment both use the same bracket syntax.A missing key is not NULL — it is the zero value
readings <- list(monday = 3.1)
print(readings$sunday) # NULL
print(is.null(readings$sunday)) package main
import "fmt"
func main() {
readings := map[string]float64{"monday": 3.1}
fmt.Println(readings["sunday"]) // 0 — the zero value, not an error
value, exists := readings["sunday"]
fmt.Println(value, exists) // 0 false — the "comma ok" idiom
} A missing key returns the type’s zero value (
0 for numbers, "" for strings) rather than R’s distinguishable NULL — a silent trap if a real zero and a missing key must be told apart. The fix is the “comma ok” idiom: value, exists := m[key] gives back a second boolean specifically for that question, the same shape errors use throughout the language.Records → Structs
list(name =, age =) → struct
patient <- list(name = "Ada", age = 36)
print(patient$name)
patient$age <- 37
print(patient$age) package main
import "fmt"
type Patient struct {
Name string
Age int
}
func main() {
ada := Patient{Name: "Ada", Age: 36}
fmt.Println(ada.Name)
ada.Age = 37
fmt.Println(ada.Age)
} R’s tagged named list becomes a declared
struct: fields typed once, up front, accessed with . instead of $. Unlike the list, no new field can be added later — the shape is fixed at the type declaration, and misspelling a field name is a compile error rather than a silent NULL.S3 generics → methods with a receiver
patient <- structure(list(name = "Ada", age = 36), class = "patient")
describe <- function(x) UseMethod("describe")
describe.patient <- function(x) cat(x$name, "is", x$age, "\n")
describe(patient) package main
import "fmt"
type Patient struct {
Name string
Age int
}
func (patient Patient) Describe() {
fmt.Println(patient.Name, "is", patient.Age)
}
func main() {
ada := Patient{Name: "Ada", Age: 36}
ada.Describe()
} S3’s generic-function-plus-class-tag dispatch becomes a method with an explicit receiver — the
(patient Patient) before the function name — attaching Describe specifically to Patient. There is no inheritance and no UseMethod dispatch chain; each type simply owns the methods declared with its name as receiver.Functions
Every parameter and return value is typed
standardize <- function(values) {
(values - mean(values)) / sd(values)
}
print(standardize(c(10, 20, 30))) package main
import (
"fmt"
"math"
)
func standardize(values []float64) []float64 {
total := 0.0
for _, value := range values {
total += value
}
average := total / float64(len(values))
varianceSum := 0.0
for _, value := range values {
varianceSum += math.Pow(value-average, 2)
}
deviation := math.Sqrt(varianceSum / float64(len(values)-1))
result := make([]float64, len(values))
for index, value := range values {
result[index] = (value - average) / deviation
}
return result
}
func main() {
fmt.Println(standardize([]float64{10, 20, 30}))
} Every parameter type and the return type are declared in the signature — calling
standardize("oops") is a compile error, not a runtime surprise. return is mandatory (no last-expression-is-the-value the way R and several other languages here allow), and there is no built-in mean/sd — base Go has almost no statistics functions at all.Multiple return values, no list() needed
min_max <- function(values) {
list(minimum = min(values), maximum = max(values))
}
result <- min_max(c(5, 2, 8, 1))
cat(result$minimum, result$maximum, "\n") package main
import "fmt"
func minMax(values []int) (int, int) {
minimum, maximum := values[0], values[0]
for _, value := range values {
if value < minimum {
minimum = value
}
if value > maximum {
maximum = value
}
}
return minimum, maximum
}
func main() {
minimum, maximum := minMax([]int{5, 2, 8, 1})
fmt.Println(minimum, maximum)
} Where R bundles multiple results into a
list() and unpacks it with $, Go returns multiple values natively — (int, int) in the signature, unpacked directly into two names at the call site. This mechanism is also the backbone of the error-handling idiom in the next section.NA & tryCatch → (value, error)
Errors are ordinary return values
safe_divide <- function(numerator, denominator) {
tryCatch({
if (denominator == 0) stop("division by zero")
numerator / denominator
}, error = function(condition) {
cat("caught:", conditionMessage(condition), "\n")
NA
})
}
print(safe_divide(10, 2))
print(safe_divide(10, 0)) package main
import (
"errors"
"fmt"
)
func safeDivide(numerator, denominator float64) (float64, error) {
if denominator == 0 {
return 0, errors.New("division by zero")
}
return numerator / denominator, nil
}
func main() {
result, err := safeDivide(10, 2)
if err != nil {
fmt.Println("caught:", err)
} else {
fmt.Println(result)
}
result, err = safeDivide(10, 0)
if err != nil {
fmt.Println("caught:", err)
} else {
fmt.Println(result)
}
} There is no exception mechanism in everyday Go: a function that can fail returns
(value, error), and the caller checks if err != nil immediately after every call — tryCatch’s job is done by an ordinary conditional, repeated at every call site rather than wrapped once around a block. It reads as more ceremony per call and gives back one guarantee tryCatch cannot: every failure point is visible in the code, not hidden until a stop() fires three functions deep.panic/recover — for the truly exceptional
check_positive <- function(value) {
if (value < 0) stop("value must be non-negative")
sqrt(value)
}
result <- tryCatch(check_positive(-4), error = function(condition) {
cat("recovered:", conditionMessage(condition), "\n")
NA
})
print(result) package main
import "fmt"
func checkPositive(value int) (result int, recovered bool) {
defer func() {
if caught := recover(); caught != nil {
fmt.Println("recovered:", caught)
recovered = true
}
}()
if value < 0 {
panic("value must be non-negative")
}
return value, false
}
func main() {
result, wasRecovered := checkPositive(-4)
fmt.Println(result, wasRecovered)
} panic/recover is the closer analog to stop/tryCatch — but idiomatic Go reserves it for programmer errors and truly unrecoverable states, never for ordinary failure the way R’s conditions handle both. Expected failures (division by zero, a missing file) get the (value, error) treatment above; panic is closer to “this should never happen.”apply Family → Goroutines
Real concurrency, not a bolted-on package
# R's concurrency is a library, and it forks whole processes:
# library(parallel)
# results <- mclapply(1:4, function(worker) worker^2, mc.cores = 4)
# print(results)
for (worker in 1:4) {
cat("worker", worker, "squared is", worker^2, "\n")
} package main
import (
"fmt"
"sync"
)
func main() {
var waitGroup sync.WaitGroup
for worker := 1; worker <= 4; worker++ {
waitGroup.Add(1)
go func(id int) {
defer waitGroup.Done()
fmt.Println("worker", id, "squared is", id*id)
}(worker)
}
waitGroup.Wait() // block until every goroutine finishes
} R’s concurrency story is a library (
parallel) that forks entire OS processes — heavyweight, and platform-dependent (mclapply does not fork on Windows). The go keyword launches a goroutine — a lightweight, runtime-scheduled unit of concurrency, thousands of which run comfortably on one machine — built into the language itself. sync.WaitGroup is how the caller waits for a batch to finish, replacing mclapply’s implicit blocking-until-done.Channels
Channels: how goroutines hand off results
# R has no equivalent — mclapply collects results for you, invisibly.
# Go makes the hand-off explicit and visible in the code:
squares <- sapply(1:4, \(worker) worker^2)
print(squares) package main
import "fmt"
func main() {
results := make(chan int, 4)
for worker := 1; worker <= 4; worker++ {
go func(id int) {
results <- id * id // send the result on the channel
}(worker)
}
total := 0
for count := 0; count < 4; count++ {
total += <-results // receive one result
}
fmt.Println(total)
} Where
mclapply silently collects every worker’s result into a list for you, Go makes the hand-off an explicit, typed channel: goroutines send with <- pointed at the channel, the receiver receives with <- pointed away from it. A buffered channel (the 4 above) lets sends proceed without a receiver standing by yet, up to that capacity.Strings
paste & sprintf → fmt verbs
name <- "Ada"
trials <- 3
cat(paste("Subject", name, "completed", trials, "trials"), "\n")
cat(sprintf("%s: %.1f%% done\n", name, 66.67)) package main
import "fmt"
func main() {
name := "Ada"
trials := 3
fmt.Println("Subject", name, "completed", trials, "trials")
fmt.Printf("%s: %.1f%% done\n", name, 66.67)
} fmt.Println takes any number of arguments the way cat does, spacing them automatically — most paste calls translate directly. fmt.Printf is nearly the same format-string dialect as sprintf (%s, %.1f, %%), one of the more comfortable landings on this page.String functions live in strings and strconv
phrase <- "stitch in time"
print(toupper(phrase))
print(nchar(phrase))
print(strsplit(phrase, " ")[[1]])
print(grepl("time", phrase)) package main
import (
"fmt"
"strings"
)
func main() {
phrase := "stitch in time"
fmt.Println(strings.ToUpper(phrase))
fmt.Println(len(phrase)) // byte length — see the note below
fmt.Println(strings.Split(phrase, " "))
fmt.Println(strings.Contains(phrase, "time"))
} Most string operations live in the
strings package as free functions (strings.ToUpper, not a method) rather than R’s standalone-function style, so the call shape is unusually close to home. One gotcha worth flagging: len(phrase) counts BYTES, not characters — correct for ASCII text like this example, but a trap the moment non-ASCII text (emoji, accented letters) enters, where []rune(phrase) is needed instead.The Deployment Story
The deployment story
# Shipping an R analysis usually means shipping R itself:
# - the R interpreter and its exact version
# - every library() dependency, often pinned with renv
# - possibly a Docker image, since "works on my machine" is real
# install.packages(c("dplyr", "ggplot2")) // Shipping a Go analysis means shipping ONE FILE:
// go build produces a single static binary — no runtime, no
// interpreter, no dependency tree to install on the target machine
//
// go build -o analyzer main.go
// scp analyzer remote-server:/usr/local/bin/
// ./analyzer # just runs — nothing else needed on that machine This is the practical payoff of the whole migration:
go build produces one statically linked binary with everything baked in — copy it to any machine of the same OS/architecture and run it, no R installation, no renv lockfile, no Docker image required just to reproduce the environment. It is the single biggest operational reason data teams add a Go service beside an R analysis pipeline. Both cells are illustrative, shown display-only.