Introduction to Julia: Basic Types, Containers, and Control Flow

Author

David Evans

Published

March 30, 2026

1 Introduction

These notes cover the foundational building blocks of the Julia programming language. Julia is a high-level, high-performance language designed for technical computing. It combines the ease-of-use of languages like Python and R with the speed of compiled languages like C and Fortran. This makes it an increasingly popular choice in economics, statistics, and the computational sciences.

In this set of notes we will cover the following topics:

  1. Primitive data types — the basic kinds of values Julia can work with (booleans, integers, floating-point numbers, and strings).
  2. Container types — data structures that hold collections of values (arrays, tuples, named tuples, and dictionaries).
  3. Control flow — tools for making decisions and repeating operations (comparisons, logical operators, if statements, and for loops).
  4. Functions — how to package reusable code into named units with inputs and outputs, including an applied example from time series econometrics.
  5. Root solving — how to use the NonlinearSolve.jl package to find zeros of nonlinear equations, with applications to CES market equilibrium.
  6. Optimization — how to use the Optim.jl package to maximize and minimize functions, with applications to CES utility and profit maximization.
  7. Broadcasting — Julia’s dot syntax for applying operations element-wise to arrays, with automatic loop fusion for performance.
  8. The type system — abstract vs concrete types, type annotations, and multiple dispatch—the mechanism that makes Julia both flexible and fast.
  9. Worked example — simulating an AR(1) process, combining types, arrays, loops, functions, and plotting into a complete application.

By the end of these notes you should be comfortable writing simple Julia programs that store data, iterate over collections, make decisions based on conditions, organize your code into reusable functions, and use packages for root solving and optimization.

2 Primitive Data Types

Every programming language has a set of primitive data types: the simplest kinds of values that everything else is built on top of. In Julia, the most important primitive types for everyday work are booleans, integers, and floating-point numbers. Understanding these types matters because Julia’s performance advantages come in large part from its type system: when the compiler knows exactly what kind of data it is working with, it can exploit it to generate fast machine code.

2.1 Booleans

A boolean is the simplest possible data type. It can take on exactly two values: true or false. Booleans arise naturally whenever we ask a yes-or-no question: Is \(x\) greater than \(y\)? Is this coefficient statistically significant? Has the optimization converged? Have we found an equilibrium?

You can create a boolean directly or by evaluating an expression:

x = true        # Assign the boolean value true directly
y = 100 < 10    # Evaluate an expression; the result (false) is stored in y
false

We can verify the type of a variable using the built-in typeof function:

typeof(y)
Bool

The output Bool confirms that y is a boolean. We will see booleans again when we discuss comparisons and if statements later in these notes.

2.2 Integers

An integer is a whole number, i.e. any value from the set \(\{\ldots, -3, -2, -1, 0, 1, 2, 3, \ldots\}\). Numbers like \(0.5\) or \(\pi\) are not integers. Integers are the natural choice whenever you are counting discrete quantities: the number of observations in a dataset, the index of an element in an array, or the number of iterations in a loop.

x = 3
y = 5
x + y
8
typeof(x)
Int64

The type Int64 tells us that Julia is storing this integer using 64 bits of memory, which allows it to represent very large numbers (up to roughly \(9.2 \times 10^{18}\)).

2.2.1 Integer Division and the Remainder Operator

Division of two integers in Julia produces a floating-point result by default, which is the mathematically natural behavior:

x / y
0.6

Sometimes, however, we care about the remainder after integer division. The modulo operator % computes this:

5 % 3    # 5 divided by 3 is 1 remainder 2
2
51 % 17  # 51 is exactly divisible by 17, so the remainder is 0
0

The modulo operator is surprisingly useful in practice. For example, you can test whether a number is even by checking whether n % 2 == 0, or test divisibility more generally. We will use it later when we write a program to find prime numbers.

2.3 Floating-Point Numbers

Most numbers in economics are not integers. Prices, interest rates, GDP growth, and regression coefficients are all real-valued quantities that require decimal representation. Julia represents these using floating-point numbers (or “floats”), which are stored in the IEEE 754 double-precision format.

Pi = 3.14159265358979323846264
typeof(Pi)
Float64

The type Float64 indicates a 64-bit floating-point number, which gives roughly 15–17 significant digits of precision. That there is a limited amount of precision can lead to numerical issues: for example if you add a very large number to a very small number you can run into rounding errors. This won’t be particularly relevant to this class but it is something to keep in mind as you work with computers. Julia also provides a built-in constant pi (with even higher precision through its Irrational type):

pi
π = 3.1415926535897...

2.3.1 Arithmetic with Floats

All the standard arithmetic operations work as you would expect:

pi * 4    # Multiplication
12.566370614359172
pi / 3    # Division
1.0471975511965976

Exponentiation uses the caret operator ^:

2^4       # 2 raised to the 4th power
16

2.3.2 Ensuring a Value Is a Float

Sometimes you want to make sure Julia treats a number as a float rather than an integer. The simplest way to do this is to include a decimal point:

x = 3     # This is an Int64
y = 3.    # This is a Float64
typeof(x), typeof(y)
(Int64, Float64)

This distinction can matter for performance. When Julia knows a variable is a Float64, it can use the appropriate hardware instructions for floating-point arithmetic. Mixing types forces Julia to convert on the fly, which can slow things down in tight loops.

2.4 Strings

A string is a sequence of characters, used to represent text. In Julia, strings are created by enclosing text in double quotes:

greeting = "Hello, world!"
typeof(greeting)
String

Note that Julia uses double quotes " " for strings. Single quotes are reserved for individual characters (Char type), which is a distinct type:

c = 'A'
typeof(c)
Char

This is a common gotcha for people coming from Python, where single and double quotes are interchangeable.

2.4.1 String Operations

Julia provides a rich set of operations for working with strings. You can concatenate (join) strings using the * operator:

first_name = "David"
last_name = "Evans"
full_name = first_name * " " * last_name
"David Evans"

If you are used to Python or R, the use of * for concatenation may seem surprising. Julia chose * because string concatenation is not commutative (\(\text{"ab"} \neq \text{"ba"}\)), and * is the standard mathematical notation for non-commutative operations (like matrix multiplication).

You can also repeat a string using the ^ operator:

"ha" ^ 3
"hahaha"

2.4.2 String Interpolation

One of the most convenient features in Julia is string interpolation: embedding the value of an expression directly inside a string using the $ sign:

name = "David"
age = 39
"My name is $name and I am $age years old."
"My name is David and I am 39 years old."

For more complex expressions, wrap them in parentheses:

x = 10
"The square of $x is $(x^2)."
"The square of 10 is 100."

String interpolation is much cleaner than manually concatenating strings with *, especially when building messages that include computed values. We will see this in action with println("Element $i of letter_list is $letter") in the enumerate example below.

2.4.3 Useful String Functions

Julia includes many built-in functions for working with strings:

s = "Economics is great"
length(s)              # Number of characters
18
uppercase(s)           # Convert to uppercase
"ECONOMICS IS GREAT"
occursin("great", s)   # Check if a substring is present
true
replace(s, "great" => "fascinating")   # Replace a substring
"Economics is fascinating"
split(s)               # Split on whitespace into an array of substrings
3-element Vector{SubString{String}}:
 "Economics"
 "is"
 "great"

The split function is particularly useful when parsing data files where fields are separated by spaces, commas, or other delimiters.

2.4.4 Converting Between Strings and Numbers

In data work you frequently need to convert between strings and numeric types. Use parse to convert a string to a number, and string to go the other direction:

parse(Int64, "42")         # String → Integer
42
parse(Float64, "3.14")     # String → Float
3.14
string(2.718)              # Number → String
"2.718"

The parse function will throw an error if the string cannot be interpreted as the requested type—for example, parse(Int64, "hello") would fail. This is useful as a built-in sanity check when reading data.

3 Container Types

Individual numbers are useful, but most real work involves collections of values. Julia provides several container types for grouping data together. We’ve already seen examples of containers come up: for example the split(s) function produced a Vector (Array). Each container type has different properties that make it suitable for different situations:

Summary of Julia’s main container types
Container Syntax Mutable? Access
Array [1, 2, 3] Yes By index
Tuple (1, 2, 3) No By index
Named Tuple (a=1, b=2) No By name or index
Dictionary Dict("a" => 1) Yes By key

The key trade-offs are mutability (can you change the contents after creation?) and access pattern (do you look things up by position or by name?). Let us look at each in turn.

3.1 Arrays

An array is the workhorse container in Julia. It is an ordered, mutable collection of elements. “Ordered” means the elements have a definite sequence (first, second, third, …), and “mutable” means you can change, add, or remove elements after the array is created.

Arrays are created using square brackets:

David = [32, "male", 71.5]    # An array containing mixed types
typeof(David)
Vector{Any} (alias for Array{Any, 1})

The type Vector{Any} tells us two things: this is a one-dimensional array (a Vector), and it can hold elements of Any type. Julia determined this because the array contains an integer, a string, and a float—three different types.

3.1.1 Typed Arrays

When all the elements in an array share the same type, Julia can be much more efficient. It stores the data in a compact, contiguous block of memory and avoids the overhead of checking types at runtime:

Davidfloat = [32.1, 16., 35]
typeof(Davidfloat)
Vector{Float64} (alias for Array{Float64, 1})

Here Julia infers that every element is a Float64, so it creates a Vector{Float64}. If you try to insert a value of the wrong type, Julia will raise an error:

Davidfloat[2] = "test"    # Cannot assign a String to a Float64 array
MethodError: Cannot `convert` an object of type String to an object of type Float64
The function `convert` exists, but no method is defined for this combination of argument types.

Closest candidates are:
  convert(::Type{T}, ::T) where T<:Number
   @ Base number.jl:6
  convert(::Type{T}, ::Number) where T<:Number
   @ Base number.jl:7
  convert(::Type{T}, ::T) where T
   @ Base Base_compiler.jl:133
  ...

Stacktrace:
 [1] setindex!(A::Vector{Float64}, x::String, i::Int64)
   @ Base ./array.jl:985
 [2] top-level scope
   @ ~/University of Oregon Dropbox/David Evans/University of Oregon/Courses/Spring 2026/EC 410/Lectures and Website/Julia/Basic Julia/JuliaBasicsNotes.qmd:287

In performance-critical code (e.g., numerical simulations, optimization routines), you should try to use typed arrays whenever possible.

3.1.2 Accessing and Modifying Arrays

You access elements of an array using square-bracket indexing:

David[2]    # Access the second element
"male"

Because arrays are mutable, you can change existing elements and append new ones:

David[3] = David[3] / 12      # Modify the third element
push!(David, 12)               # Append a new element to the end
David
4-element Vector{Any}:
 32
   "male"
  5.958333333333333
 12

Note the exclamation mark in push!. In Julia, function names ending with ! follow a convention indicating that the function modifies its argument in place rather than returning a new copy. This is an important idiom you will see throughout the language.

3.1.3 Indexing: Julia Starts at 1

ImportantJulia is 1-indexed!

Unlike Python and C (which start at 0), Julia arrays start at index 1. This is a common source of bugs when switching between languages. The first element is L[1], not L[0].

Here are the key indexing conventions:

  • L[1] — the first element
  • L[i] — the element at position \(i\)
  • L[end] — the last element
  • L[end-1] — the second-to-last element

The end keyword is particularly convenient because it saves you from having to compute the length of the array.

3.1.4 Array Slicing

You can extract a contiguous subset of an array using the colon : operator. The expression L[a:b] returns a new array containing elements at positions \(a, a+1, \ldots, b\):

print(David[1:3])    # Equivalent to David[1:end-1] in this case
Any[32, "male", 5.958333333333333]

Slicing is useful for extracting subsets of data—for example, taking the first \(n\) observations from a dataset, or splitting a time series into training and test sets.

3.2 Tuples

A tuple is similar to an array in that it holds an ordered collection of elements, but with one critical difference: tuples are immutable. Once created, their contents cannot be changed. This makes tuples useful for representing fixed collections of values—things that should not accidentally be modified.

Tuples are created using parentheses (or simply by separating values with commas):

d = (39, "male", 71.5)      # Parentheses notation
d = 39, "male", 71.5        # Equivalent: comma-separated values
print(typeof(d))
Tuple{Int64, String, Float64}

Notice that Julia tracks the type of each element individually. The type Tuple{Int64, String, Float64} tells us exactly what is in each position.

Attempting to modify a tuple produces an error:

d[1] = 5    # Tuples are immutable!
MethodError: no method matching setindex!(::Tuple{Int64, String, Float64}, ::Int64, ::Int64)
The function `setindex!` exists, but no method is defined for this combination of argument types.
Stacktrace:
 [1] top-level scope
   @ ~/University of Oregon Dropbox/David Evans/University of Oregon/Courses/Spring 2026/EC 410/Lectures and Website/Julia/Basic Julia/JuliaBasicsNotes.qmd:355

3.2.1 Tuple Unpacking

One of the most useful features of tuples is unpacking: assigning each element of a tuple to a separate variable in a single statement:

integers = (4, 5, 6)
x, y, z = integers
x
4
z
6

This works with arrays too:

x, y, z = David    # Unpacks the first three elements
x
32

Tuple unpacking is especially handy when a function returns multiple values. For example, a function that performs a statistical test might return both the test statistic and the p-value as a tuple, and you can unpack them directly into named variables.

3.2.2 Named Tuples

Named tuples combine the immutability of regular tuples with the readability of named access. Instead of remembering that the age is at position 1 and the height is at position 3, you can access fields by name:

person = (name="David", age=39, height=71.5)
typeof(person)
@NamedTuple{name::String, age::Int64, height::Float64}
person.name       # Access by name (preferred for readability)
"David"
person[1]         # Can still access by index if needed
"David"
TipWhen to Use Named Tuples

Named tuples are ideal when you want to return multiple values from a function with descriptive labels. They are lightweight (no need to define a struct, we’ll talk about this later) and self-documenting. For example, a calibration function might return (β=0.96, σ=2.0, δ=0.025) so the caller immediately knows which parameter is which.

3.3 Dictionaries

A dictionary (or Dict) is a mutable container that maps keys to values. Unlike arrays and tuples, dictionaries are not ordered by position—instead, you look up values by their key. This makes dictionaries ideal when you need to associate labels with data.

David = Dict("age" => 39, "gender" => "male", "height" => 71.5)
print(typeof(David))
Dict{String, Any}
David["age"]    # Look up a value by its key
39

3.3.1 Adding and Modifying Entries

Because dictionaries are mutable, you can add new key-value pairs or change existing ones:

David["job"] = "Economist"
David["job"]
"Economist"

In the dictionary above, "age", "gender", "height", and "job" are the keys, and 39, "male", 71.5, and "Economist" are the corresponding values.

Dictionaries are especially useful when dealing with structured data where positional indexing would be confusing. Imagine an array with 20 elements—was the interest rate at position 7 or position 12? With a dictionary you simply write params["interest_rate"] and the code is self-documenting.

3.3.2 Choosing the Right Container

To summarize, here are some rules of thumb:

  • Use an array when you have an ordered collection of items that may need to change, especially if all elements are the same type (for performance).
  • Use a tuple when you have a small, fixed collection that should not change—for example, the return values of a function.
  • Use a named tuple when you want immutable data with descriptive field names.
  • Use a dictionary when you need to look up values by a meaningful key rather than by position.

4 Iteration with For Loops

Much of computational economics involves repeating an operation many times: simulating an economy for \(T\) periods, computing a sum over \(N\) observations, or iterating on a value function until convergence. The for loop is the basic tool for this kind of repetition.

4.1 Basic For Loop Syntax

The general form of a for loop in Julia is:

for variable in iterable
    # code that may depend on variable
end

The iterable is any object Julia can step through one element at a time—an array, a tuple, a range like 1:10, and so on. For each element in the iterable, Julia executes the code inside the loop with variable set to the current element.

Here is a simple example:

for i in 1:5
    println(i)
end
1
2
3
4
5

The expression 1:5 creates a range object representing the sequence \(1, 2, 3, 4, 5\). The loop variable i takes on each value in turn, and println prints it followed by a newline. You can think of code like this a rolling out a sequence of lines of code

println(1) #i=1
println(2) #i=2
println(3) #i=3
println(4) #i=4
println(5) #i=5
1
2
3
4
5

but often times there are additional optimizations the compiler can run behind the scenes that you don’t have to worry about.

We can put any code we like inside the loop body:

for i in 1:5
    x = i^2
    println(x)
end
1
4
9
16
25

4.2 Looping Over Collections

For loops are not limited to numeric ranges. You can loop directly over the elements of any collection:

David = [32, "male", 71.5];    # Semicolon suppresses output
for element in David
    println(element)
end
32
male
71.5

This style—iterating over elements directly rather than over indices—is considered more idiomatic in Julia and tends to produce cleaner, more readable code:

x_values = [4, 2, 3]
for x in x_values
    println(x * x)
end
16
4
9

Compare that with the index-based alternative, which is more verbose and harder to read:

for i in 1:length(x_values)
    println(x_values[i] * x_values[i])
end
16
4
9

Both produce the same result, but the first version communicates intent more clearly: “for each value in the list, print its square.”

4.3 Using enumerate for Index-Value Pairs

Sometimes you need both the index and the value during iteration. Rather than managing a counter variable yourself, Julia provides the enumerate function:

TipBest Practice

Use enumerate when you need both the index and the value. It is cleaner and less error-prone than managing a separate counter.

letter_list = ['a', 'b', 'c']
for (i, letter) in enumerate(letter_list)
    println("Element $i of letter_list is $letter")
end
Element 1 of letter_list is a
Element 2 of letter_list is b
Element 3 of letter_list is c

Notice the use of string interpolation with the $ sign: inside a string, $i is replaced by the value of i. This is a convenient Julia feature for building formatted output.

4.4 Example: Computing an Inner Product

A common operation in economics and statistics is the inner product (or dot product) of two vectors. Given \(x = [x_1, \ldots, x_n]\) and \(y = [y_1, \ldots, y_n]\), the inner product is:

\[\langle x, y \rangle = \sum_{i=1}^{n} x_i \cdot y_i\]

Here is how you might compute it with a for loop:

x = [3, 57, 2, 1, 8, 5]
y = [4.3, 23, 2, 47, 42, 6]
ret = 0.0
for i in 1:length(x)
    ret = ret + x[i] * y[i]    # Equivalently: ret += x[i] * y[i]
end
ret
1740.9

Note that we initialize ret = 0.0 (a float) rather than ret = 0 (an integer). This ensures the accumulator has the right type from the start, avoiding a type conversion partway through the loop.

(In practice, Julia provides built-in functions for this—dot(x, y) from the LinearAlgebra standard library—but it is instructive to see how you would implement it yourself.)

4.5 Example: Computing a Geometric Sum

In macroeconomics, the geometric sum appears frequently when computing present discounted values. Given a discount factor \(\beta \in (0,1)\), the sum:

\[S = \sum_{j=0}^{50} \beta^j\]

can be computed as follows:

β = 0.95    # Type \beta then press Tab to get the Greek letter
ret = 0.0
for j in 0:50
    ret += β^j
end
print(ret)
18.538045469742436
TipGreek Letters in Julia

Julia supports Unicode identifiers, which means you can use Greek letters in your code. To type β, enter \beta and press Tab in the Julia REPL or a Jupyter/Quarto code cell. This makes your code look much closer to the mathematical notation—a significant readability advantage over languages that restrict you to ASCII variable names.

As a check: the closed-form solution for a geometric series is \(S = \frac{1 - \beta^{51}}{1 - \beta}\). For \(\beta = 0.95\), this gives approximately \(\frac{1 - 0.95^{51}}{0.05} \approx 18.34\), which matches our loop result.

5 Comparisons and Logical Operators

Now that we know how to store data and iterate, the next step is making decisions—executing different code depending on whether a condition is true or false. This requires two ingredients: comparisons (which produce boolean values) and logical operators (which combine boolean values).

5.1 Comparison Operators

Julia provides the standard comparison operators you would expect from mathematics:

Operator Meaning
< Less than
> Greater than
<= Less than or equal to
>= Greater than or equal to
== Equal to
!= Not equal to

Each comparison returns a boolean:

x, y = 1, 2
x < y
true
x > y
false

5.1.1 Chained Comparisons

A particularly nice feature of Julia is that you can chain comparisons, just as you would in mathematical notation. The expression a < b < c is equivalent to a < b && b < c, but more readable:

1 < 2 < 3
true
1 <= 2 <= 3 > 4
false

This is very handy for checking whether a value falls within a range: 0 < x < 1 reads naturally as “\(x\) is between 0 and 1.”

5.1.2 Assignment vs. Equality

WarningCommon Mistake

Do not confuse = (assignment) with == (equality test). Writing x = 2 sets x to 2, while x == 2 asks whether x is equal to 2. This is an easy mistake to make early on.

x = 1       # Assignment: x is now 1
x == 2      # Equality test: is x equal to 2?
false
x != 2      # Inequality test: is x NOT equal to 2?
true

5.2 Logical Operators

Logical operators let you combine multiple boolean expressions into a single condition. Julia uses the following syntax:

Expression Meaning Result
P && Q Logical AND true only if both P and Q are true
P \|\| Q Logical OR true if at least one of P or Q is true
!P Logical NOT Negates P: true becomes false and vice versa

5.2.1 Logical AND (&&)

The && operator returns true only when both sides are true:

1 < 2 && 'f' in "foo"    # Both true → true
true
1 < 2 && 'g' in "foo"    # Second is false → false
false

5.2.2 Logical OR (||)

The || operator returns true when at least one side is true:

1 < 2 || 'g' in "foo"    # First is true → true (second doesn't matter)
true

Julia uses short-circuit evaluation for both && and ||. For &&, if the first operand is false, Julia does not even evaluate the second (since the result must be false regardless). For ||, if the first operand is true, the second is skipped. This can improve performance and is also used as a control-flow idiom in Julia.

5.2.3 Logical NOT (!)

The ! operator simply flips a boolean value:

!true
false
!!true    # Double negation returns the original value
true

6 Conditional Execution with If Statements

Comparisons and logical operators become truly powerful when combined with conditional statements. An if statement lets you execute a block of code only when a specified condition holds.

6.1 Basic If Statement

The simplest form is:

if condition
    # code to execute when condition is true
end
x = 5
if x > 4
    println("Yay!")
end
Yay!

If the condition evaluates to false, the code block is simply skipped:

if x < 4
    println("This code never runs.")
end

Nothing is printed, because 5 < 4 is false.

6.2 If-Else Statements

Often you want to do one thing when a condition is true and something else when it is false. The else clause handles this:

x = Int[]    # Initialize an empty array of integers
for i in 1:10
    if 3 <= i <= 7
        push!(x, i^2)     # Square the value if i is between 3 and 7
    else
        push!(x, i)       # Otherwise, just use i as-is
    end
end
println(x)
[1, 2, 9, 16, 25, 36, 49, 8, 9, 10]

Notice how the chained comparison 3 <= i <= 7 makes the condition easy to read. The resulting array contains the original values for \(i \in \{1,2,8,9,10\}\) and the squared values for \(i \in \{3,4,5,6,7\}\).

Julia also supports elseif for checking multiple conditions in sequence:

if condition1
    # code for condition1
elseif condition2
    # code for condition2
else
    # code if neither condition is met
end

6.3 Example: Finding Prime Numbers

Let us put everything together with a more substantial example: finding all prime numbers from 2 to 500. A prime number is a natural number greater than 1 that is divisible only by 1 and itself. The algorithm below uses a straightforward approach: for each candidate number, check whether any previously found prime divides it evenly.

primes = Int[]                    # Initialize an empty array of integers
for i in 2:500                    # Loop through candidate numbers
    isprime = true                # Assume i is prime until proven otherwise
    for p in primes               # Check each known prime
        if i % p == 0             # If p divides i evenly...
            isprime = false       # ...then i is not prime
        end
    end
    if isprime                    # If no known prime divided i...
        push!(primes, i)          # ...then i is prime; add it to our list
    end
end
print(primes)
[2, 3, 5, 7, 11, 13, 17, 19, 23, 29  …  443, 449, 457, 461, 463, 467, 479, 487, 491, 499]

This example uses nearly every concept from these notes:

  • An array (primes) to store the results, with push! to grow it dynamically.
  • Nested for loops to iterate over candidates and known primes.
  • The modulo operator (%) to test divisibility.
  • An if statement to conditionally classify each number.
  • A boolean variable (isprime) to track the result of the divisibility checks.

While this algorithm is not the most efficient way to find primes (a Sieve of Eratosthenes would be faster), it illustrates how simple programming constructs combine to solve a nontrivial problem.

7 Functions

So far we have been writing code as a sequence of statements: one after another, top to bottom. This works for small scripts, but as programs grow it quickly becomes unwieldy. Functions solve this problem by letting you package a piece of code into a reusable, named unit that takes inputs and produces outputs. Functions are arguably the single most important organizational tool in programming. They are also the secret sauce in julia: julia optimizes functions by compiling them into machine code.

There are several reasons to use functions:

  • Avoid repetition. If you need to perform the same computation in multiple places, write it once as a function and call it wherever you need it.
  • Manage complexity. Functions let you break a large problem into small, understandable pieces. Each function does one thing, and you can reason about it in isolation.
  • Improve readability. A well-named function call like simulateAR1(0.0, 100) communicates intent far more clearly than the 10 lines of loop code it encapsulates.
  • Enable testing. You can test each function independently to make sure it works correctly before combining them.

Julia’s functions are very flexible. Any object can be passed as an argument, even other functions! Functions can return any kind of object, including other functions. And any number of functions can be defined in a single file.

7.1 Anonymous (Lambda) Functions

The simplest way to create a function in Julia is the arrow syntax:

f = x -> x^2
f(2)
4
f(3)
9

Here x -> x^2 creates an anonymous function (also called a lambda function): a function with no name. We then assign it to the variable f so we can call it later. Anonymous functions are especially useful when you need a short, throwaway function—for example, as an argument to map, filter, or sort.

7.2 Short-Form Function Definition

For simple one-line functions that you want to give a name to, Julia provides a compact assignment form:

g(x) = x^2
g(5)
25

This is equivalent to writing g = x -> x^2, but reads more naturally. You will see this style used frequently for mathematical functions that can be expressed in a single expression.

7.3 Multi-Line Functions with function

For anything more than a single expression, use the function keyword:

function h(x)
    return x^2
end
h(pi)
9.869604401089358

The return keyword specifies what value the function gives back to the caller. If you omit return, Julia automatically returns the value of the last expression evaluated in the function body. While this implicit return is convenient for short functions, using return explicitly is good practice for clarity, especially in longer functions.

7.4 Default Arguments

You can give function arguments default values, making them optional when the function is called:

function greet(name, greeting="Hello")
    println("$greeting, $(name)!")
end
greet("David")
Hello, David!
greet("David", "Howdy")
Howdy, David!

Default arguments are specified with = in the function signature. When the caller omits that argument, the default is used. This is particularly useful for parameters that have a “standard” value but occasionally need to be overridden—like the persistence parameter in a time series model, or the tolerance in an optimization routine.

7.5 Multiple Return Values

Functions can return multiple values by returning a tuple. The caller can then unpack the results:

function divide(a, b)
    quotient = a ÷ b     # integer division
    remainder = a % b
    return quotient, remainder
end
q, r = divide(17, 5)
println("17 ÷ 5 = $q remainder $r")
17 ÷ 5 = 3 remainder 2

This pattern is extremely common in scientific computing. For example, a function that estimates a regression model might return both the coefficient vector and the standard errors. Recall from our earlier discussion that named tuples are an even more readable way to return multiple values: return (coef=β, se=σ).

7.6 Docstrings

Julia supports docstrings—documentation strings placed immediately before a function definition. These serve as built-in documentation that can be accessed from the REPL using ?:

"""
    square(x)

Return the square of `x`.
"""
function square(x)
    return x^2
end
Main.Notebook.square

Writing docstrings is a good habit. They make your code self-documenting and help both your collaborators and your future self understand what each function does, what arguments it expects, and what it returns.

7.7 Application: Simulating an AR(1) Process

Let us put functions to work with an example from time series econometrics. An AR(1) process (first-order autoregressive process) is defined by:

\[ x_{t} = \rho x_{t-1} + \epsilon_{t} \quad\text{where}\quad \epsilon_{t} \sim \mathcal{N}(0,1) \]

Here \(x_t\) is a random variable at time \(t\). Its value depends on the previous period’s value \(x_{t-1}\) (scaled by the persistence parameter \(\rho\)) plus a random shock \(\epsilon_t\) drawn from a standard normal distribution. AR(1) processes are ubiquitous in macroeconomics and finance—they are used to model productivity shocks, income processes, interest rates, and many other time series.

7.7.1 Building Up: Random Numbers

Before writing the full simulation function, let us see how Julia generates random numbers. The randn() function draws from the standard normal distribution \(\mathcal{N}(0,1)\):

println(randn())
println(randn())
println(randn())
0.5823714071281189
-0.37028314179894617
-1.0187146419647202

You can also generate an entire array of random numbers at once:

randn(5)
5-element Vector{Float64}:
 -1.9550833235826852
  1.318778505892125
 -1.1540191806490612
  1.4015403045706611
  2.080821559546796

7.7.2 Building Up: A Single Step

If \(x_{t-1} = 5\) and \(\rho = 0.8\), a single step of the AR(1) process looks like:

x = 5.0
ρ = 0.8
print* x + randn())
3.130730344453524

7.7.3 Multiple Periods with a For Loop

Now let us simulate multiple periods. Starting from \(x_1 = 5\) with \(\rho = 0.8\), we simulate \(T = 10\) periods by pre-allocating an array and filling it in with a for loop:

T = 10
x = zeros(T)       # pre-allocate array of zeros
x[1] = 5.0         # set initial condition
ρ = 0.8

# simulate using a for loop
for t in 2:T
    x[t] = ρ * x[t-1] + randn()    # update using previous period's value
end

@show x;
x = [5.0, 1.9901403891536105, 1.0628777341184208, 0.42537370890233284, -1.028302628752003, -0.7875900096614094, 0.45060446339243265, -1.0033683897154098, 1.3682017097539751, 0.28429685684561756]

Each iteration updates \(x_t = \rho \, x_{t-1} + \varepsilon_t\). Because \(|\rho| < 1\), the series mean-reverts toward zero: large deviations are pulled back, and the long-run average is zero regardless of the initial condition.

7.7.4 Plotting the Results

We can visualize the simulated path using Plots.jl:

using Plots
plot(x, linewidth=2)
xlabel!("time")
ylabel!("x_t")
title!("AR(1) Process")

The xlabel!, ylabel!, and title! functions modify the current plot in place (note the ! convention). We will use Plots.jl extensively throughout this course.

7.7.5 The Simulation Function

Now we can wrap the full simulation into a function. Notice how it uses several concepts we have covered: arrays, for loops, default arguments, and a docstring:

"""
    simulateAR1(x0, T, ρ=0.8)

Simulate `T` periods of the AR(1) process xₜ = ρ*xₜ₋₁ + εₜ
where εₜ ~ N(0,1).

# Arguments
- `x0`: initial value for x
- `T`: number of periods to simulate
- `ρ`: persistence parameter (default 0.8)

# Returns
- Vector of length `T` containing the simulated path
"""
function simulateAR1(x0, T, ρ=0.8)
    x = zeros(T)       # Pre-allocate array
    x[1] = x0          # Set initial condition
    for t in 2:T
        x[t] = ρ * x[t-1] + randn()
    end
    return x
end
Main.Notebook.simulateAR1

A few things to note about this function:

  • The array is pre-allocated with zeros(T) rather than grown incrementally with push!. This is much more efficient for numerical work because Julia can allocate a single contiguous block of memory up front.
  • The default value ρ=0.8 means the caller can simply write simulateAR1(0.0, 100) for the most common case.
  • The docstring documents the arguments and return value, following Julia conventions.

7.7.6 Using the Function

Now we can simulate and plot paths for different values of \(\rho\):

plot(simulateAR1(0., 100, 0.1), linewidth=1.5, label="ρ = 0.1")
plot!(simulateAR1(0., 100, 0.5), linewidth=1.5, label="ρ = 0.5")
plot!(simulateAR1(0., 100, 0.99), linewidth=1.5, label="ρ = 0.99")
xlabel!("time")

The first plot call creates a new figure, and plot! adds additional lines to it. Higher values of \(\rho\) produce more persistent series—shocks take longer to die out, and the series wanders farther from zero. When \(\rho = 0.99\), a shock at time \(t\) still has a noticeable effect many periods later. When \(\rho = 0.1\), the series is nearly white noise. This is an important intuition for understanding macroeconomic dynamics: highly persistent processes (like log GDP) behave very differently from low-persistence processes (like inflation surprises).

8 Root Solving with NonlinearSolve

Many important problems in economics can be formulated as finding the root of a function—that is, finding a value \(x^*\) such that \(f(x^*) = 0\). Market equilibrium conditions, Euler equations from dynamic optimization, and fixed-point problems all take this form. While simple linear equations can be solved by hand, realistic models—especially those with constant-elasticity (CES) functional forms—involve nonlinear equations that require numerical methods.

The NonlinearSolve.jl package provides robust, modern algorithms for solving systems of nonlinear equations in Julia. It is part of the SciML ecosystem and is the recommended choice for new Julia projects.

8.1 The NonlinearSolve Interface

using NonlinearSolve

To use NonlinearSolve, you follow a three-step pattern:

  1. Define your function as f(u, p) where u is a vector of unknowns and p is a parameter argument (use _ if you don’t need parameters).
  2. Create a problem with NonlinearProblem(f, u0) where u0 is your initial guess.
  3. Solve it with sol = solve(prob).

The basic calling pattern is:

prob = NonlinearProblem(f, u0)    # create the problem with initial guess u0
sol = solve(prob)                  # solve it
sol.u                              # the solution x*
sol.retcode                        # convergence status

The f(u, p) signature is a convention of the SciML ecosystem. The second argument p allows you to pass parameters (like elasticities or prices) into the problem without hard-coding them. If you don’t need parameters, simply use _ as a placeholder.

8.2 Example: CES Market Equilibrium

Consider a market with constant-elasticity demand and supply:

\[D(p) = A\, p^{-\epsilon_d}, \quad S(p) = B\, p^{\,\epsilon_s}\]

These are the workhorse functional forms in trade and public finance because the elasticity parameters \(\epsilon_d\) and \(\epsilon_s\) are constant—they do not depend on the price level. The equilibrium price \(p^*\) satisfies \(D(p^*) = S(p^*)\), or equivalently, the excess demand \(D(p) - S(p) = 0\).

Unlike linear demand and supply, this equation is nonlinear and generally has no closed-form solution. This is exactly the kind of problem where a numerical solver earns its keep:

function excess_demand(p, _)
    A, εd = 100.0, 2.0        # demand parameters
    B, εs = 20.0, 1.5         # supply parameters
    D = A * p[1]^(-εd)        # CES demand
    S = B * p[1]^εs           # CES supply
    return [D - S]            # excess demand
end

prob = NonlinearProblem(excess_demand, [1.0])
sol = solve(prob)
sol.u
1-element Vector{Float64}:
 1.583819608766579

Notice that even though we have a single equation, p is still a vector (of length 1) and the function returns a vector—NonlinearSolve always works with vectors. The _ in the function signature indicates that we are not using the parameter argument. We can verify the solution satisfies \(D(p^*) = S(p^*)\) by plugging back in:

A, εd = 100.0, 2.0        # demand parameters
B, εs = 20.0, 1.5         # supply parameters
D = A * sol.u[1]^(-εd)
S = B * sol.u[1]^εs
println("D = $D, S = $S")
D = 39.86470631277377, S = 39.86470631277376

Demand and supply are equal at the equilibrium price, confirming the solver found the correct root.

8.3 Example: Two-Good CES Equilibrium

NonlinearSolve really shines when you have a system of equations. Consider two goods with constant-elasticity demand that includes cross-price effects. When goods are substitutes, an increase in the price of one good raises demand for the other:

\[D_1(p_1, p_2) = 100\, p_1^{-2}\, p_2^{0.5}, \quad S_1(p_1) = 20\, p_1^{1.5}\] \[D_2(p_1, p_2) = 80\, p_2^{-1.5}\, p_1^{0.3}, \quad S_2(p_2) = 15\, p_2^{2}\]

The two equilibrium conditions \(D_1 = S_1\) and \(D_2 = S_2\) must hold simultaneously, so the prices are jointly determined. We can solve this system numerically:

function two_market(p, _)
    D1 = 100 * p[1]^(-2.0) * p[2]^(0.5)    # good 1 demand
    S1 = 20 * p[1]^(1.5)                     # good 1 supply
    D2 = 80 * p[2]^(-1.5) * p[1]^(0.3)      # good 2 demand
    S2 = 15 * p[2]^(2.0)                     # good 2 supply
    return [D1 - S1, D2 - S2]
end

prob = NonlinearProblem(two_market, [2.0, 2.0])
sol = solve(prob)
sol.u
2-element Vector{Float64}:
 1.7069573735223966
 1.6889575066262879

You can verify convergence by checking sol.retcode:

sol.retcode
ReturnCode.Success = 1

The cross-price elasticities (\(p_2^{0.5}\) in the demand for good 1, \(p_1^{0.3}\) in the demand for good 2) mean that a supply shock to one market spills over into the other—a feature that makes analytical solutions intractable but poses no difficulty for a numerical solver.

TipTips for Using NonlinearSolve
  • Initial guess matters. Nonlinear solvers are iterative and start from your initial guess. A bad guess can lead to convergence failure or finding a different root (if multiple roots exist). Use economic intuition to pick a reasonable starting point.
  • Always check convergence. After solving, check sol.retcode to confirm the solver found a solution successfully.
  • The f(u, p) signature. Your function must accept two arguments even if you don’t use parameters—use _ as a placeholder for the unused argument.

9 Optimization with Optim

Optimization is at the heart of economics. Consumers maximize utility, firms maximize profit, econometricians maximize likelihood functions, and social planners minimize welfare losses. While simple optimization problems can be solved analytically using calculus (set the derivative to zero and solve), many realistic problems—especially those involving nonlinear functions, multiple variables, or numerical simulations—require computational methods.

The Optim.jl package provides a collection of optimization algorithms for unconstrained (and box-constrained) minimization in Julia.

9.1 The Optim Interface

using Optim

An important convention: Optim.jl minimizes functions by default. If you want to maximize a function \(f(x)\), you minimize \(-f(x)\). This is a standard convention in numerical optimization libraries.

For scalar (univariate) functions, provide lower and upper bounds:

result = optimize(f, lower, upper)     # uses Brent's method

For multivariate functions, provide an initial guess:

result = optimize(f, x0)              # uses Nelder-Mead by default

In both cases, the result object contains:

  • Optim.minimizer(result) — the optimal input \(x^*\)
  • Optim.minimum(result) — the optimal function value \(f(x^*)\)

9.2 Example: CES Utility Maximization

Consider a consumer with CES preferences over two goods:

\[U(x_1, x_2) = \bigl(\alpha\, x_1^{\rho} + (1-\alpha)\, x_2^{\rho}\bigr)^{1/\rho}\]

where \(\sigma = 1/(1-\rho)\) is the elasticity of substitution. The consumer faces a budget constraint \(p_1 x_1 + p_2 x_2 = I\). By substituting \(x_2 = (I - p_1 x_1)/p_2\), we can reduce this to a single-variable optimization problem:

function neg_utility(x1; α=0.5, ρ=-1.0, p1=1.0, p2=2.0, I=100.0)
    x2 = (I - p1 * x1) / p2
    return -* x1^ρ + (1-α) * x2^ρ)^(1/ρ)
end

result = optimize(neg_utility, 1.0, 99.0)
x1_star = Optim.minimizer(result)
x2_star = (100.0 - x1_star) / 2.0
println("x₁ = $(round(x1_star, digits=2)), x₂ = $(round(x2_star, digits=2))")
x₁ = 41.42, x₂ = 29.29

Here we use optimize(f, lower, upper) which applies Brent’s method for scalar optimization over the interval \([1, 99]\) (the bounds ensure both goods are consumed). With \(\rho = -1\) (so \(\sigma = 1/2\)), the goods are complements—the consumer wants to balance consumption of both goods rather than specialize. Note the use of keyword arguments (α=0.5, etc.) to make the function parameters clear while keeping the optimizer interface clean.

9.2.1 Plotting Utility Along the Budget Constraint

Because the budget constraint pins down \(x_2 = (I - p_1 x_1)/p_2\), utility is effectively a function of \(x_1\) alone. We can visualize this to confirm the optimizer found the peak:

x1_grid = LinRange(1.0, 99.0, 100)                # 100 points between 1 and 99
utility = [-neg_utility(x1) for x1 in x1_grid]    # compute utility for each x1

plot(x1_grid, utility,
     xlabel = "x₁", ylabel = "U(x₁, x₂(x₁))",
     title = "CES Utility along the Budget Constraint",
     lw = 2, legend = false)
vline!([x1_star], ls = :dash, color = :red, lw = 1.5)
annotate!(x1_star + 2, maximum(utility) - 0.1,
          text("x₁* = $(round(x1_star, digits=1))", :left, 9))

The dashed red line marks the optimum found by Optim. The concavity of the CES utility function guarantees a unique interior maximum—the consumer balances spending across both goods.

9.3 Example: Short-Run Profit Maximization

Here is a second scalar optimization example. Consider a firm with Cobb-Douglas production \(Y = A\bar{K}^{\alpha} L^{1-\alpha}\) that chooses labor \(L\) in the short run, taking capital \(\bar{K}\) as fixed. Revenue is \(p \cdot Y\) and the only variable cost is the wage bill \(wL\), so profit is:

\[\pi(L) = p \cdot A\, \bar{K}^{\alpha}\, L^{1-\alpha} - wL\]

Because \(1-\alpha < 1\), the production function exhibits diminishing marginal returns to labor, which guarantees an interior optimum. We can verify this analytically: the first-order condition \(p \cdot A\bar{K}^{\alpha}(1-\alpha)L^{-\alpha} = w\) gives \(L^* = \bigl(\frac{p A \bar{K}^{\alpha}(1-\alpha)}{w}\bigr)^{1/\alpha}\). Let us confirm numerically:

function neg_profit(L; A=1.0, K̄=10.0, α=1/3, p=10.0, w=5.0)
    Y = A *^α * L^(1-α)
    return -(p * Y - w * L)          # negate for minimization
end

result = optimize(neg_profit, 0.1, 1000.0)
L_star = Optim.minimizer(result)
println("Optimal labor: $(round(L_star, digits=2))")
println("Maximum profit: $(round(-Optim.minimum(result), digits=2))")
Optimal labor: 23.7
Maximum profit: 59.26

Notice that this is again a scalar problem, so we use optimize(f, lower, upper) with bounds that keep \(L > 0\). The keyword arguments (A=1.0, K̄=10.0, etc.) set the model parameters as defaults, keeping the function signature clean for the optimizer while making the economics transparent.

TipTips for Using Optim
  • Optim minimizes. To maximize \(f(x)\), minimize \(-f(x)\). Don’t forget to negate when interpreting the optimal function value!
  • Choice of algorithm matters. The default Nelder-Mead is derivative-free and robust but slow. For smooth functions, gradient-based methods like LBFGS() are much faster. For scalar problems, optimize(f, lower, upper) uses Brent’s method, which is efficient and reliable.
  • Initial guess matters. For multivariate problems, the optimizer starts from your initial guess and may find a local (not global) minimum. Try multiple starting points if you suspect multiple local minima.
  • Check convergence. Use Optim.converged(result) to verify the optimizer successfully found a solution.

10 Broadcasting and Vectorized Operations

In the examples so far, most of our operations have acted on individual numbers—we wrote x^2 to square a single value, or used a for loop to process each element of an array one at a time. But a huge amount of scientific computing involves applying the same operation to every element of an array. Julia provides a powerful mechanism for this called broadcasting, and understanding it will make your code both shorter and faster.

10.1 The Dot Syntax

Broadcasting in Julia uses the dot syntax: placing a . before (or after) an operator or function name tells Julia to apply the operation element-wise across arrays. For arithmetic operators, the dot goes before the operator:

x = [1.0, 2.0, 3.0, 4.0, 5.0]
y = x .^ 2          # Square each element
5-element Vector{Float64}:
  1.0
  4.0
  9.0
 16.0
 25.0
z = x .+ 10.0       # Add 10 to each element
5-element Vector{Float64}:
 11.0
 12.0
 13.0
 14.0
 15.0
w = x .* y           # Multiply corresponding elements
5-element Vector{Float64}:
   1.0
   8.0
  27.0
  64.0
 125.0

Without the dot, Julia would try to interpret x + 10.0 as adding a scalar to a vector (which does work for + and - as a convenience), but x * y would attempt matrix multiplication, which is not defined for two vectors of the same shape. The dot syntax makes element-wise intent explicit and unambiguous.

For functions, the dot goes after the function name. This tells Julia to apply the function to each element of the input array individually:

log.(x)              # Natural log of each element
5-element Vector{Float64}:
 0.0
 0.6931471805599453
 1.0986122886681098
 1.3862943611198906
 1.6094379124341003
sqrt.(x)             # Square root of each element
5-element Vector{Float64}:
 1.0
 1.4142135623730951
 1.7320508075688772
 2.0
 2.23606797749979

You can even broadcast user-defined functions. Suppose you have a function g(x) = x^2 + 3x - 1. Writing g.(x) applies it to every element of x:

g(x) = x^2 + 3x - 1
g.(x)
5-element Vector{Float64}:
  3.0
  9.0
 17.0
 27.0
 39.0

This works for any function—built-in or user-defined. Julia automatically generates an efficient loop behind the scenes.

10.2 Why Broadcasting Matters

Broadcasting is not just a convenience—it can significantly improve performance. Consider two ways to compute \(\log(c)\) for a vector of consumption levels. The first uses an explicit loop:

result = zeros(length(c))
for i in 1:length(c)
    result[i] = log(c[i])
end

The second uses broadcasting:

result = log.(c)

Both produce the same answer, but the broadcast version is typically faster. The reason is memory allocation. When you chain operations like x .+ y .* z, Julia fuses the broadcasts into a single loop: it computes the result element by element without creating intermediate temporary arrays. An explicit chain of operations like temp = y .* z; result = x .+ temp would allocate memory for temp, fill it in, and then allocate memory for result—two passes through memory instead of one. This fusion is automatic whenever you use dots, and it becomes increasingly important as arrays grow large.

10.3 Economic Application: Utility Over a Consumption Grid

A common task in computational economics is evaluating a utility function over a grid of consumption values. For example, when solving a consumption-savings problem, you might want to compute \(u(c) = \log(c)\) for many possible consumption levels:

consumption = range(0.1, 10.0, length=100)    # 100 points from 0.1 to 10.0
utility_grid = log.(consumption)               # log utility for each level
100-element Vector{Float64}:
 -2.3025850929940455
 -1.6094379124341003
 -1.2039728043259361
 -0.916290731874155
 -0.6931471805599453
 -0.5108256237659907
 -0.35667494393873245
 -0.2231435513142097
 -0.10536051565782628
  0.0
  ⋮
  2.2192034840549946
  2.2300144001592104
  2.2407096892759584
  2.2512917986064953
  2.2617630984737906
  2.272125885509337
  2.2823823856765264
  2.2925347571405443
  2.302585092994046
using Plots
plot(collect(consumption), utility_grid, linewidth=2, label="u(c) = log(c)",
     xlabel="Consumption", ylabel="Utility", legend=:bottomright)

The concavity of the log function is clearly visible: the marginal utility of consumption falls as \(c\) increases. This is the fundamental property that drives risk aversion and consumption smoothing in economic models. Broadcasting made it trivial to compute utility over the entire grid in a single expression.

10.4 Broadcasting with Multiple Arrays

Broadcasting also handles operations between arrays of different shapes. Julia’s rule is simple: dimensions are compatible if they are equal or if one of them is 1. A scalar is treated as an array with all dimensions equal to 1, so it broadcasts with anything.

For example, suppose you have a vector of wages \(w\) and want to compute after-tax income for several tax rates \(\tau\):

wages = [30_000.0, 50_000.0, 75_000.0, 100_000.0]
tax_rates = [0.10, 0.20, 0.30]

# Reshape tax_rates to a row vector so broadcasting gives a matrix
after_tax = wages .* (1 .- tax_rates')
4×3 Matrix{Float64}:
 27000.0  24000.0  21000.0
 45000.0  40000.0  35000.0
 67500.0  60000.0  52500.0
 90000.0  80000.0  70000.0

The result is a \(4 \times 3\) matrix: each row corresponds to a wage level and each column to a tax rate. Julia automatically expanded the column vector of wages and the row vector of tax rates to produce every combination. This kind of “outer product” broadcasting is extremely useful for computing policy functions, transition matrices, and comparative statics grids.

TipWhen to Use Broadcasting vs Loops

Use broadcasting when you are applying the same operation to every element of an array, especially for simple mathematical transformations. It is concise, readable, and often faster due to loop fusion. Use explicit loops when the computation at step \(t\) depends on the result from step \(t-1\) (as in our AR(1) simulation), when the logic inside the loop involves complex branching, or when you need fine-grained control over the iteration. Both are perfectly idiomatic in Julia—the language is designed to make loops fast, so you never need to “vectorize for speed” the way you do in Python or R.

11 The Type System

Julia’s type system is one of the features that sets it apart from languages like Python and R. While those languages are “dynamically typed” in a way that hides types from the user, Julia makes types a central, visible part of the language. Understanding the type system is important for two reasons: it is the foundation of Julia’s performance (the compiler generates specialized machine code for each type combination), and it enables multiple dispatch, a powerful programming paradigm that makes code both flexible and fast.

11.1 Abstract and Concrete Types

Julia distinguishes between abstract types and concrete types. Concrete types are the types that actual values have—Float64, Int64, Bool, String. Every value in Julia has exactly one concrete type, and you can check it with typeof:

typeof(3.14)
Float64
typeof(42)
Int64
typeof(true)
Bool

Abstract types, on the other hand, cannot be instantiated directly. They exist to organize concrete types into a hierarchy. For example, Number is an abstract type that encompasses all numeric types. Integer is also abstract—it encompasses Int64, Int32, UInt8, and other integer types. You can think of the type hierarchy as a tree: abstract types are the interior nodes, and concrete types are the leaves.

11.2 The Type Hierarchy

You can explore the hierarchy with two built-in functions. supertype tells you a type’s parent in the hierarchy, and subtypes tells you its children:

supertype(Float64)
AbstractFloat
supertype(AbstractFloat)
Real
subtypes(Number)
4-element Vector{Any}:
 Base.MultiplicativeInverses.MultiplicativeInverse
 Complex
 Plots.Measurement
 Real
subtypes(Integer)
3-element Vector{Any}:
 Bool
 Signed
 Unsigned

The full path from Float64 to the top of the hierarchy is Float64 → AbstractFloat → Real → Number → Any. Every type in Julia ultimately descends from Any, the root of the type tree. This hierarchy matters because functions can be defined to accept any type at a given level of the tree. A function that accepts Number will work with Float64, Int64, BigFloat, and any other numeric type.

11.3 Type Annotations

You can annotate function arguments with types using the :: operator. This tells Julia (and the reader) what kinds of inputs the function expects:

function double(x::Number)
    return 2 * x
end
double(3.14)
6.28
double(42)
84

The annotation x::Number means this function accepts any numeric type—integers, floats, rationals, and so on. If you try to pass a non-numeric argument, Julia will raise an error:

double("hello")
MethodError: no method matching double(::String)
The function `double` exists, but no method is defined for this combination of argument types.

Closest candidates are:
  double(::Number)
   @ Main.Notebook ~/University of Oregon Dropbox/David Evans/University of Oregon/Courses/Spring 2026/EC 410/Lectures and Website/Julia/Basic Julia/JuliaBasicsNotes.qmd:1383

Stacktrace:
 [1] top-level scope
   @ ~/University of Oregon Dropbox/David Evans/University of Oregon/Courses/Spring 2026/EC 410/Lectures and Website/Julia/Basic Julia/JuliaBasicsNotes.qmd:1397

Type annotations on function arguments are optional in Julia. If you omit them, the function accepts any type (equivalent to annotating with ::Any). All of the functions we have written so far omitted type annotations, and they worked fine. So why bother?

The answer is multiple dispatch: the ability to define different methods of the same function for different argument types.

11.4 Multiple Dispatch

Multiple dispatch is Julia’s core programming paradigm. The idea is simple: you can define several versions of a function with the same name but different type signatures, and Julia automatically selects the right version based on the types of the arguments at call time.

Here is an example with a log_utility function. The log utility of a single consumption level is a scalar computation, but the utility of a consumption vector requires element-wise application:

"""
    log_utility(c::Float64)

Compute log utility for a single consumption level.
"""
function log_utility(c::Float64)
    return log(c)
end

"""
    log_utility(c::Vector{Float64})

Compute log utility for a vector of consumption levels.
"""
function log_utility(c::Vector{Float64})
    return log.(c)
end
Main.Notebook.log_utility

Now Julia dispatches to the correct method automatically:

log_utility(2.5)                          # calls the scalar method
0.9162907318741551
log_utility([1.0, 2.0, 3.0, 4.0])        # calls the vector method
4-element Vector{Float64}:
 0.0
 0.6931471805599453
 1.0986122886681098
 1.3862943611198906

Each call goes to the version of log_utility whose type signature matches the argument. This is multiple dispatch in action: the same function name, but the behavior depends on the types of the inputs. Julia selects the most specific matching method at runtime.

You can see all the methods defined for a function using the methods function:

ImportantWhy Julia’s Type System Enables Performance

When you call log_utility(2.5), Julia knows the argument is a Float64. The JIT (just-in-time) compiler generates specialized machine code for that specific type: it uses hardware floating-point instructions, avoids type checks, and produces code that runs as fast as hand-written C. When you call log_utility([1.0, 2.0, 3.0, 4.0]), Julia generates different specialized code optimized for Vector{Float64}. This is fundamentally different from Python, where the interpreter must check the type of every value at every operation. Julia’s approach—“write generic code, get specialized performance”—is the secret to its speed. You write high-level, readable code, and the compiler does the work of making it fast.

Multiple dispatch is pervasive in Julia. Operators like + and * are ordinary functions with hundreds of methods—one for adding two integers, one for adding two floats, one for adding a float and an integer, one for matrix multiplication, and so on. When you write 3.14 + 42, Julia dispatches to a method that promotes the integer to a float and then performs floating-point addition. This is all happening behind the scenes, but it is the reason Julia can be both high-level and high-performance.

12 Worked Example: Simulating an AR(1) Process

Let us close these notes by combining everything we have learned—types, arrays, loops, functions, broadcasting, and plotting—into a complete worked example. We will simulate an AR(1) process, compute summary statistics, and compare them to the theoretical predictions. This example also serves as a preview of the Markov chain and dynamic programming tools we will use throughout the rest of the course.

12.1 The Model

An AR(1) (first-order autoregressive) process is defined by:

\[ y_t = \rho \, y_{t-1} + \varepsilon_t, \qquad \varepsilon_t \sim \mathcal{N}(0, \sigma^2) \]

where \(\rho \in (-1, 1)\) is the persistence parameter and \(\sigma > 0\) is the standard deviation of the innovation. The process is stationary when \(|\rho| < 1\), meaning it has a well-defined long-run distribution that does not depend on the initial condition. The unconditional (long-run) mean is \(\mathbb{E}[y_t] = 0\), and the unconditional variance is:

\[ \text{Var}(y_t) = \frac{\sigma^2}{1 - \rho^2} \]

This formula reveals an important economic intuition: as \(\rho\) approaches 1, the variance explodes. Highly persistent processes wander widely because shocks accumulate rather than dissipating. When \(\rho = 0\), the process is simply white noise with variance \(\sigma^2\)—each period is independent of the last.

AR(1) processes are the workhorse model in macroeconomics and finance. Log productivity in the RBC model follows an AR(1). Log income in life-cycle consumption models is typically AR(1). Interest rates, exchange rates, and inflation are all commonly modeled as AR(1) processes. Understanding the properties of this simple process is essential preparation for the dynamic models we will study later.

12.2 The Simulation Function

We already wrote a basic simulateAR1 function in the Functions section. Here we extend it to include the innovation standard deviation \(\sigma\) as a parameter and use a named tuple to return both the simulated path and the parameter values:

using Random

"""
    simulate_ar1(ρ, σ, T; y0=0.0)

Simulate `T` periods of the AR(1) process yₜ = ρ yₜ₋₁ + σ εₜ
where εₜ ~ N(0, 1).

# Arguments
- `ρ`: persistence parameter (must satisfy |ρ| < 1 for stationarity)
- `σ`: standard deviation of innovations
- `T`: number of periods to simulate
- `y0`: initial value (default 0.0)

# Returns
- Named tuple `(path, ρ, σ)` containing the simulated path and parameters
"""
function simulate_ar1::Float64, σ::Float64, T::Int; y0::Float64=0.0)
    y = zeros(T)
    y[1] = y0
    for t in 2:T
        y[t] = ρ * y[t-1] + σ * randn()
    end
    return (path = y, ρ = ρ, σ = σ)
end
Main.Notebook.simulate_ar1

A few things to note about this function. First, it uses type annotations (ρ::Float64, T::Int) to make the expected inputs explicit. Second, it pre-allocates the output array with zeros(T) for efficiency. Third, it returns a named tuple so the caller can access both the simulated data and the parameters that generated it. Fourth, the loop is unavoidable here: each value \(y_t\) depends on the previous value \(y_{t-1}\), so we cannot use broadcasting.

12.3 Simulating and Plotting

Let us simulate a single path with \(\rho = 0.9\), \(\sigma = 0.5\), and \(T = 200\) periods:

Random.seed!(42)    # Set seed for reproducibility
result = simulate_ar1(0.9, 0.5, 200)

plot(result.path, linewidth=1.5, label="ρ = $(result.ρ), σ = $(result.σ)",
     xlabel="Time", ylabel="yₜ", title="Simulated AR(1) Process",
     legend=:topright)
hline!([0.0], linestyle=:dash, color=:gray, label="E[yₜ] = 0")

The series fluctuates around its unconditional mean of zero, but because \(\rho = 0.9\) is high, the deviations are persistent—the series drifts above or below zero for extended stretches before reverting. This is exactly the kind of behavior we see in macroeconomic time series like detrended GDP or the unemployment rate.

Now let us compare paths with different levels of persistence. Setting the same random seed for each simulation ensures the underlying shocks \(\varepsilon_t\) are identical, so any differences in the paths are due entirely to the persistence parameter:

plot(title="AR(1) Paths: Varying Persistence", xlabel="Time", ylabel="yₜ")
for ρ in [0.2, 0.5, 0.9]
    Random.seed!(123)
    sim = simulate_ar1(ρ, 0.5, 200)
    plot!(sim.path, linewidth=1.5, label="ρ = $ρ")
end
plot!()

The visual difference is striking. The low-persistence path (\(\rho = 0.2\)) bounces rapidly around zero—shocks die out within a few periods. The high-persistence path (\(\rho = 0.9\)) exhibits long, slow swings that resemble business cycles. The persistence parameter \(\rho\) is doing all the work: the same sequence of shocks produces dramatically different dynamics depending on how quickly those shocks dissipate.

12.4 Comparing Sample and Theoretical Moments

The theory predicts that the unconditional variance of a stationary AR(1) process is \(\sigma^2 / (1 - \rho^2)\). With a long enough simulation, the sample variance should be close to this theoretical value. Let us check with a simulation of \(T = 100{,}000\) periods:

using Statistics

Random.seed!(999)
ρ, σ = 0.9, 0.5
long_sim = simulate_ar1(ρ, σ, 100_000)

theoretical_var = σ^2 / (1 - ρ^2)
sample_var = var(long_sim.path)

println("Theoretical variance: $(round(theoretical_var, digits=4))")
println("Sample variance:      $(round(sample_var, digits=4))")
println("Theoretical mean:     0.0")
println("Sample mean:          $(round(mean(long_sim.path), digits=4))")
Theoretical variance: 1.3158
Sample variance:      1.2946
Theoretical mean:     0.0
Sample mean:          -0.0088

With 100,000 observations, the sample moments are very close to their theoretical counterparts. This convergence is a consequence of the law of large numbers: as the sample size grows, sample averages converge to population means. The small remaining discrepancy is simply sampling variation—it would shrink further with even more observations.

TipConnection to Markov Chains

The AR(1) process is a continuous-state Markov process: the distribution of \(y_{t+1}\) depends only on \(y_t\), not on any earlier history. In the next set of lectures, we will study discrete-state Markov chains, where the state variable takes on a finite number of values and transitions are governed by a matrix \(P\). A key technique in computational economics is discretizing continuous AR(1) processes into finite Markov chains using methods like Tauchen (1986) or Rouwenhorst (1995). This lets us apply the powerful matrix algebra tools of Markov chains to problems that are naturally continuous, like the RBC model’s productivity process.

13 Summary

In these notes we covered the essential building blocks of Julia programming:

Primitive data types form the foundation. Booleans (true/false) represent logical values. Integers (Int64) represent whole numbers. Floating-point numbers (Float64) represent real-valued quantities with decimal precision. Strings (String) represent text, with powerful interpolation and manipulation capabilities.

Container types let us organize data into collections:

  • Arrays (Vector) are ordered and mutable—the go-to choice for most numerical work.
  • Tuples are ordered and immutable—useful for fixed collections and function return values.
  • Named tuples add descriptive labels to immutable data.
  • Dictionaries (Dict) provide key-value lookup for labeled data.

Control flow gives our programs the ability to make decisions and repeat operations:

  • For loops iterate over ranges and collections.
  • Comparisons (<, >, ==, !=, <=, >=) produce boolean values.
  • Logical operators (&&, ||, !) combine boolean expressions.
  • If statements execute code conditionally.

Functions let us package reusable code into named units:

  • Anonymous functions (x -> x^2) for quick, throwaway operations.
  • Short-form (f(x) = x^2) for simple named functions.
  • Multi-line (function ... end) for complex logic with return.
  • Default arguments make parameters optional.
  • Docstrings provide built-in documentation.

Root solving with NonlinearSolve.jl lets us find values \(x^*\) where \(f(x^*) = 0\):

  • Define f(u, p) and create a NonlinearProblem(f, u0).
  • Solve with sol = solve(prob) and read the answer from sol.u.
  • Applications include CES market equilibrium and solving systems of nonlinear equations.

Optimization with Optim.jl lets us maximize and minimize functions:

  • Optim.jl minimizes by default—negate your function to maximize.
  • For scalar functions: optimize(f, lower, upper).
  • For multivariate functions: optimize(f, x0).
  • Use Optim.minimizer(result) and Optim.minimum(result) to extract solutions.

Broadcasting lets us apply operations element-wise to entire arrays using the dot syntax (.+, .*, f.(x)), with automatic loop fusion for performance.

The type system organizes Julia’s types into a hierarchy of abstract and concrete types. Type annotations and multiple dispatch let you define different methods of the same function for different argument types, and the JIT compiler generates specialized machine code for each type combination.

The AR(1) worked example tied everything together: we wrote a typed, documented simulation function, plotted paths with varying persistence, and confirmed that sample moments converge to their theoretical values—previewing the Markov chain tools we will use throughout the course.

ImportantKey Things to Remember
  • Julia is 1-indexed: the first element of an array is L[1], and L[end] gives the last element.
  • Functions ending with ! (like push!) modify their arguments in place.
  • You can use Greek letters and other Unicode characters as variable names.
  • Use typed arrays when possible for better performance.
  • Pre-allocate arrays (e.g., zeros(T)) rather than growing them with push! in performance-sensitive code.
  • Broadcasting with dot syntax (e.g., log.(x), x .+ y) applies operations element-wise and fuses loops for performance.
  • Multiple dispatch selects the right function method based on argument types—use type annotations to define specialized methods.
  • NonlinearSolve functions use the f(u, p) signature; always check sol.retcode.
  • When using Optim, remember it minimizes—negate to maximize.

With these fundamentals in hand, you are ready to move on to more advanced topics like working with matrices, Markov chains, and tackling dynamic economic models in Julia.