DEV Community

Cover image for Learning Elixir: Maps
João Paulo Abreu
João Paulo Abreu

Posted on

Learning Elixir: Maps

Maps are like your smartphone's contacts app where each person has a clear name and you can instantly find their information. Instead of scrolling through contact #1, #2, #3 hoping to find the right person, you simply search for "Sofia" or "Marcus" and get exactly what you need. Think of %{name: "Sofia", role: "Engineer", phone: "+1-555-0123"} as a contact entry where each piece of information has a meaningful label—you know exactly where to find someone's phone number, email, or work details. You can easily add new contacts, update existing information, or organize details however makes sense. In this article, we'll explore how maps work, when they're the perfect choice for your data, and the patterns that make them indispensable for building robust Elixir applications.

Note: The examples in this article use Elixir 1.18.4. While most operations should work across different versions, some functionality might vary.

Table of Contents

Introduction

Maps are Elixir's key-value data structure, perfect for storing and organizing data where each piece has a meaningful name or identifier. They're one of the most versatile and commonly used data structures in Elixir applications.

What makes maps special:

  • Key-value pairs: Each value is associated with a unique key
  • Fast lookups: Direct access to values by their keys
  • Flexible keys: Keys can be atoms, strings, or any Elixir term
  • Dynamic size: Maps can grow and shrink as needed
  • Immutable: Like all Elixir data, maps cannot be modified in place
  • Pattern matching friendly: Excellent support for destructuring

Think of maps as contact entries where each piece of information has a clear purpose:

  • Employee contact: %{name: "Sofia", role: "developer", department: "engineering"}
  • Server details: %{hostname: "api.server.com", port: 4000, ssl: true}
  • API response: %{"status" => "success", "data" => [...], "message" => "OK"}

Maps excel when you need to:

  • Store related information with meaningful labels
  • Look up values by name rather than position
  • Represent JSON-like data structures
  • Build flexible, evolving data schemas

Understanding Maps

The Basics

Maps are collections of key-value pairs enclosed in %{} syntax:

# Empty map
empty = %{}

# Map with atom keys (most common)
product = %{name: "Gaming Laptop", price: 1299.99, category: "electronics"}

# Map with string keys (common for external data)
api_config = %{"endpoint" => "api.service.com", "timeout" => 5000, "retries" => 3}

# Mixed key types (possible but not recommended)
mixed = %{:status => "active", "count" => 42, 1 => "priority"}

# Maps can contain any data types as values
complex = %{
  task_id: 42,
  title: "Deploy to production",
  tags: [:urgent, :backend],
  metadata: %{created_at: ~D[2024-01-15], assigned: true},
  transformer: fn x -> String.upcase(x) end
}
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> employee = %{name: "Sofia", department: "Engineering", level: "Senior"}
%{department: "Engineering", level: "Senior", name: "Sofia"}

iex> api_config = %{"endpoint" => "api.service.com", "timeout" => 5000}
%{"endpoint" => "api.service.com", "timeout" => 5000}

iex> %{}
%{}
Enter fullscreen mode Exit fullscreen mode

Key Types and Conventions

# Atom keys - preferred for internal data
internal = %{user_id: 123, status: :active, type: :premium}

# String keys - common for external data (JSON, APIs)
external = %{"user_id" => 123, "status" => "active", "type" => "premium"}

# Shorthand syntax for atom keys
shorthand = %{title: "DevOps Lead", experience: 5}
# Equivalent to: %{:title => "DevOps Lead", :experience => 5}

# When keys are dynamic variables
key = :dynamic_key
dynamic = %{key => "value"}  # Results in %{:dynamic_key => "value"}
computed = %{key => "value", department: "Engineering"}
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> %{title: "DevOps Lead", experience: 5}
%{experience: 5, title: "DevOps Lead"}

iex> key = :status
:status

iex> %{key => "value", brand: "Toyota"}
%{brand: "Toyota", status: "value"}
Enter fullscreen mode Exit fullscreen mode

Map Properties

product = %{sku: "LAP-001", category: "electronics", price: 1299.99}

# Get map size
size = map_size(product)  # 3

# Check if key exists
has_sku = Map.has_key?(product, :sku)           # true
has_description = Map.has_key?(product, :description)  # false

# Get all keys
keys = Map.keys(product)       # [:category, :price, :sku] (order may vary)

# Get all values
values = Map.values(product)   # ["electronics", 1299.99, "LAP-001"] (order may vary)
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> product = %{sku: "LAP-001", category: "electronics", price: 1299.99}
%{category: "electronics", price: 1299.99, sku: "LAP-001"}

iex> map_size(product)
3

iex> Map.has_key?(product, :sku)
true

iex> Map.keys(product)
[:category, :price, :sku]
Enter fullscreen mode Exit fullscreen mode

Creating and Updating Maps

Creating Maps

# Literal syntax
task = %{title: "Deploy API", priority: :urgent}

# From keyword lists
from_keywords = Map.new([brand: "Tesla", model: "Model 3"])

# From lists of tuples
from_tuples = Map.new([{"sensor_id", "TEMP-001"}, {"___location", "Server Room"}])

# With Enum.into
from_enum = ["a", "b", "c"] |> Enum.with_index() |> Enum.into(%{})
# Results in %{"a" => 0, "b" => 1, "c" => 2}

# Using Map.put to build gradually
empty = %{}
step1 = Map.put(empty, :event, "Tech Conference 2024")
step2 = Map.put(step1, :capacity, 500)
# Results in %{capacity: 500, event: "Tech Conference 2024"}
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> Map.new([brand: "Tesla", model: "Model 3"])
%{brand: "Tesla", model: "Model 3"}

iex> ["a", "b", "c"] |> Enum.with_index() |> Enum.into(%{})
%{"a" => 0, "b" => 1, "c" => 2}

iex> %{} |> Map.put(:event, "Tech Conference 2024") |> Map.put(:capacity, 500)
%{capacity: 500, event: "Tech Conference 2024"}
Enter fullscreen mode Exit fullscreen mode

Updating Maps

Maps are immutable, so "updating" creates new maps:

original = %{sensor_id: "TEMP-001", reading: 23.5, ___location: "Server Room"}

# Update existing key
with_new_reading = %{original | reading: 24.1}
# Results in %{___location: "Server Room", reading: 24.1, sensor_id: "TEMP-001"}

# Add new key (use Map.put for new keys)
with_timestamp = Map.put(original, :timestamp, ~U[2024-01-15 14:30:00Z])

# Update multiple keys
updated = %{original | reading: 24.1, ___location: "Data Center"}

# Conditional update with Map.put_new (only if key doesn't exist)
maybe_added = Map.put_new(original, :unit, "celsius")
already_exists = Map.put_new(original, :sensor_id, "TEMP-002")  # Won't change sensor_id
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> original = %{sensor_id: "TEMP-001", reading: 23.5, ___location: "Server Room"}
%{___location: "Server Room", reading: 23.5, sensor_id: "TEMP-001"}

iex> %{original | reading: 24.1}
%{___location: "Server Room", reading: 24.1, sensor_id: "TEMP-001"}

iex> Map.put(original, :timestamp, ~U[2024-01-15 14:30:00Z])
%{___location: "Server Room", reading: 23.5, sensor_id: "TEMP-001", timestamp: ~U[2024-01-15 14:30:00Z]}

iex> Map.put_new(original, :unit, "celsius")
%{___location: "Server Room", reading: 23.5, sensor_id: "TEMP-001", unit: "celsius"}

iex> original
%{___location: "Server Room", reading: 23.5, sensor_id: "TEMP-001"}
Enter fullscreen mode Exit fullscreen mode

Advanced Update Patterns

defmodule InventoryUpdater do
  # Update with function
  def increment_stock(product) do
    Map.update(product, :stock_count, 0, fn current_stock -> current_stock + 1 end)
  end

  # Update existing key, or use default if key doesn't exist
  def add_view_count(product) do
    Map.update(product, :view_count, 1, fn count -> count + 1 end)
  end

  # Merge maps (right side wins conflicts)
  def merge_product_data(product, updates) do
    Map.merge(product, updates)
  end

  # Merge with custom conflict resolution
  def smart_merge(product, updates) do
    Map.merge(product, updates, fn _key, old_val, new_val ->
      if new_val != nil, do: new_val, else: old_val
    end)
  end

  # Delete keys
  def remove_internal_data(product) do
    Map.drop(product, [:cost_price, :supplier_id])
  end
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> product = %{sku: "LAP-001", stock_count: 10}
%{sku: "LAP-001", stock_count: 10}

iex> InventoryUpdater.increment_stock(product)
%{sku: "LAP-001", stock_count: 11}

iex> InventoryUpdater.add_view_count(product)
%{sku: "LAP-001", stock_count: 10, view_count: 1}

iex> updates = %{stock_count: 15, ___location: "Warehouse A"}
%{___location: "Warehouse A", stock_count: 15}

iex> InventoryUpdater.merge_product_data(product, updates)
%{___location: "Warehouse A", sku: "LAP-001", stock_count: 15}
Enter fullscreen mode Exit fullscreen mode

Accessing Map Values

Basic Access Methods

order = %{id: "ORD-2024-001", total: 299.99, status: :shipped}

# Bracket access (returns nil if key doesn't exist)
order_id = order[:id]          # "ORD-2024-001"
missing = order[:tracking]      # nil

# Map.get with default
total = Map.get(order, :total)                    # 299.99
tracking = Map.get(order, :tracking, "N/A")       # "N/A"

# Dot access (only for atom keys, raises if key doesn't exist)
status_dot = order.status        # :shipped
# order.tracking               # ** (KeyError) key :tracking not found

# Map.fetch (returns {:ok, value} or :error)
{:ok, total} = Map.fetch(order, :total)       # {:ok, 299.99}
:error = Map.fetch(order, :tracking)          # :error

# Map.fetch! (raises if key doesn't exist)
status_bang = Map.fetch!(order, :status)     # :shipped
# Map.fetch!(order, :tracking)              # ** (KeyError)
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> order = %{id: "ORD-2024-001", total: 299.99, status: :shipped}
%{id: "ORD-2024-001", status: :shipped, total: 299.99}

iex> order[:id]
"ORD-2024-001"

iex> order[:tracking]
nil

iex> Map.get(order, :tracking, "N/A")
"N/A"

iex> order.status
:shipped

iex> Map.fetch(order, :total)
{:ok, 299.99}

iex> Map.fetch(order, :tracking)
:error
Enter fullscreen mode Exit fullscreen mode

Safe Access Patterns

defmodule SafeAccess do
  # Safe tracking extraction
  def get_tracking(order) do
    case Map.fetch(order, :tracking_number) do
      {:ok, tracking} -> {:ok, tracking}
      :error -> {:error, :tracking_not_found}
    end
  end

  # With default transformation
  def get_display_status(order) do
    order
    |> Map.get(:custom_status)
    |> case do
      nil -> Map.get(order, :status, :unknown)
      custom_status -> custom_status
    end
  end

  # Multiple key access with validation
  def get_order_summary(order) do
    with {:ok, id} <- Map.fetch(order, :id),
         {:ok, total} <- Map.fetch(order, :total) do
      {:ok, "Order #{id}: $#{total}"}
    else
      :error -> {:error, :incomplete_order_info}
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> order = %{id: "ORD-001", total: 299.99, tracking_number: "TRK-123"}
%{id: "ORD-001", total: 299.99, tracking_number: "TRK-123"}

iex> SafeAccess.get_tracking(order)
{:ok, "TRK-123"}

iex> SafeAccess.get_display_status(order)
:unknown

iex> incomplete = %{id: "ORD-002"}
%{id: "ORD-002"}

iex> SafeAccess.get_order_summary(incomplete)
{:error, :incomplete_order_info}

iex> SafeAccess.get_order_summary(order)
{:ok, "Order ORD-001: $299.99"}
Enter fullscreen mode Exit fullscreen mode

Pattern Matching with Maps

Pattern matching with maps is one of the most powerful features for destructuring and validating data:

Basic Pattern Matching

defmodule MapPatterns do
  # Match specific keys
  def format_event(%{title: title}) do
    "Event: #{title}"
  end

  # Match multiple keys
  def event_summary(%{title: title, capacity: capacity}) do
    "#{title} - Max capacity: #{capacity} attendees"
  end

  # Match with guards
  def check_large_event(%{capacity: capacity} = event) when capacity >= 1000 do
    {:large_event, event}
  end
  def check_large_event(event) do
    {:small_event, event}
  end

  # Partial matching (map can have more keys)
  def get_event_id(%{id: id}) do
    id
  end

  # Match nested maps
  def get_venue_city(%{venue: %{city: city}}) do
    city
  end
  def get_venue_city(_), do: "Unknown"
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> event = %{title: "Tech Conference 2024", capacity: 500, ___location: "Convention Center"}
%{capacity: 500, ___location: "Convention Center", title: "Tech Conference 2024"}

iex> MapPatterns.format_event(event)
"Event: Tech Conference 2024"

iex> MapPatterns.event_summary(event)
"Tech Conference 2024 - Max capacity: 500 attendees"

iex> MapPatterns.check_large_event(event)
{:small_event, %{capacity: 500, ___location: "Convention Center", title: "Tech Conference 2024"}}

iex> event_with_id = %{id: 789, title: "DevOps Summit", category: :technical}
%{category: :technical, id: 789, title: "DevOps Summit"}

iex> MapPatterns.get_event_id(event_with_id)
789
Enter fullscreen mode Exit fullscreen mode

Advanced Pattern Matching

defmodule AdvancedPatterns do
  # Match and extract with variable capture
  def process_task(%{title: title, tags: tags} = task) do
    if :urgent in tags do
      {:priority_task, title, task}
    else
      {:regular_task, title, task}
    end
  end

  # Pattern match in function heads for different cases
  def handle_api_response(%{"status" => "success", "data" => data}) do
    {:ok, data}
  end

  def handle_api_response(%{"status" => "error", "message" => message}) do
    {:error, message}
  end

  def handle_api_response(response) do
    {:unknown, response}
  end

  # Match with pin operator (match against existing variable)
  def find_task_by_priority(tasks, target_priority) do
    Enum.find(tasks, fn %{priority: ^target_priority} -> true; _ -> false end)
  end

  # Complex nested pattern matching
  def extract_sensor_coordinates(%{device: %{___location: {lat, lon}}}) do
    {:ok, lat, lon}
  end
  def extract_sensor_coordinates(_), do: {:error, :no_coordinates}
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> urgent_task = %{title: "Fix production bug", tags: [:urgent, :backend]}
%{tags: [:urgent, :backend], title: "Fix production bug"}

iex> AdvancedPatterns.process_task(urgent_task)
{:priority_task, "Fix production bug", %{tags: [:urgent, :backend], title: "Fix production bug"}}

iex> api_success = %{"status" => "success", "data" => ["sensor1", "sensor2"]}
%{"data" => ["sensor1", "sensor2"], "status" => "success"}

iex> AdvancedPatterns.handle_api_response(api_success)
{:ok, ["sensor1", "sensor2"]}

iex> tasks = [%{title: "Deploy API", priority: :high}, %{title: "Update docs", priority: :low}]
[%{priority: :high, title: "Deploy API"}, %{priority: :low, title: "Update docs"}]

iex> AdvancedPatterns.find_task_by_priority(tasks, :high)
%{priority: :high, title: "Deploy API"}
Enter fullscreen mode Exit fullscreen mode

Pattern Matching in case Expressions

defmodule ResponseProcessor do
  def process_api_response(response) do
    case response do
      %{status: 200, body: %{"data" => data}} ->
        {:success, data}

      %{status: 404} ->
        {:error, :not_found}

      %{status: status} when status >= 500 ->
        {:error, :server_error}

      %{status: status, body: %{"error" => message}} ->
        {:error, "HTTP #{status}: #{message}"}

      %{error: reason} ->
        {:error, reason}

      _ ->
        {:error, :unknown_response}
    end
  end

  def categorize_user(user) do
    case user do
      %{age: age, role: :admin} when age >= 21 ->
        :senior_admin

      %{age: age, role: :admin} when age < 21 ->
        :junior_admin

      %{age: age, subscription: :premium} when age >= 18 ->
        :premium_adult

      %{age: age} when age >= 18 ->
        :regular_adult

      %{age: age} when age < 18 ->
        :minor

      _ ->
        :unknown
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> good_response = %{status: 200, body: %{"data" => ["item1", "item2"]}}
%{body: %{"data" => ["item1", "item2"]}, status: 200}

iex> ResponseProcessor.process_api_response(good_response)
{:success, ["item1", "item2"]}

iex> not_found = %{status: 404, body: %{"error" => "Not found"}}
%{body: %{"error" => "Not found"}, status: 404}

iex> ResponseProcessor.process_api_response(not_found)
{:error, :not_found}

iex> junior_admin = %{name: "Sofia", age: 19, role: :admin}
%{age: 19, name: "Sofia", role: :admin}

iex> ResponseProcessor.categorize_user(junior_admin)
:junior_admin

iex> regular_dev = %{name: "Marcus", age: 25, role: :developer}
%{age: 25, name: "Marcus", role: :developer}

iex> ResponseProcessor.categorize_user(regular_dev)
:regular_adult
Enter fullscreen mode Exit fullscreen mode

Essential Map Functions

Basic Map Operations

base_config = %{host: "localhost", port: 4000, ssl: true}

# Adding and updating
updated = Map.put(base_config, :timeout, 5000)
port_updated = Map.put(base_config, :port, 8080)

# Conditional operations
maybe_added = Map.put_new(base_config, :retries, 3)
wont_change = Map.put_new(base_config, :host, "remote.server.com")  # host already exists

# Removing keys
without_ssl = Map.delete(base_config, :ssl)
clean_config = Map.drop(base_config, [:ssl, :port])

# Taking specific keys
subset = Map.take(base_config, [:host, :port])
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> base_config = %{host: "localhost", port: 4000, ssl: true}
%{host: "localhost", port: 4000, ssl: true}

iex> Map.put(base_config, :timeout, 5000)
%{host: "localhost", port: 4000, ssl: true, timeout: 5000}

iex> Map.delete(base_config, :ssl)
%{host: "localhost", port: 4000}

iex> Map.take(base_config, [:host, :port])
%{host: "localhost", port: 4000}
Enter fullscreen mode Exit fullscreen mode

Transformation Functions

defmodule MapTransformations do
  # Transform all values
  def increment_numbers(map) do
    Map.new(map, fn {key, value} ->
      new_value = if is_number(value), do: value + 1, else: value
      {key, new_value}
    end)
  end

  # Filter by condition
  def filter_adults(users) do
    users
    |> Enum.filter(fn {_id, %{age: age}} -> age >= 18 end)
    |> Map.new()
  end

  # Transform keys
  def stringify_keys(map) do
    Map.new(map, fn {key, value} -> {to_string(key), value} end)
  end

  def atomize_keys(map) do
    Map.new(map, fn {key, value} -> {String.to_atom(key), value} end)
  end

  # Combine multiple maps
  def merge_user_data(base_data, profile_data, preferences) do
    base_data
    |> Map.merge(profile_data)
    |> Map.merge(preferences)
  end
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> test_map = %{temperature: 23, status: "active", score: 85}
%{score: 85, status: "active", temperature: 23}

iex> MapTransformations.increment_numbers(test_map)
%{score: 86, status: "active", temperature: 24}

iex> string_map = %{"sensor_type" => "temperature", "reading" => "23.5"}
%{"reading" => "23.5", "sensor_type" => "temperature"}

iex> MapTransformations.atomize_keys(string_map)
%{reading: "23.5", sensor_type: "temperature"}

iex> MapTransformations.stringify_keys(%{sensor_type: :temperature, reading: 23.5})
%{"reading" => 23.5, "sensor_type" => "temperature"}
Enter fullscreen mode Exit fullscreen mode

Working with Collections of Maps

defmodule MapCollections do
  # Process list of product maps
  def get_names(products) do
    Enum.map(products, &Map.get(&1, :name))
  end

  # Filter and transform
  def get_expensive_skus(products) do
    products
    |> Enum.filter(fn %{price: price} -> price >= 1000 end)
    |> Enum.map(fn %{sku: sku} -> sku end)
  end

  # Group by attribute
  def group_by_price_range(products) do
    products
    |> Enum.group_by(fn %{price: price} ->
      cond do
        price < 100 -> :budget
        price < 1000 -> :mid_range
        true -> :premium
      end
    end)
  end

  # Calculate statistics
  def calculate_average_price(products) do
    prices = Enum.map(products, &Map.get(&1, :price))
    Enum.sum(prices) / length(prices)
  end

  # Find by criteria
  def find_premium_product(products) do
    Enum.find(products, fn product ->
      Map.get(product, :category) == :premium
    end)
  end
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> products = [%{name: "Laptop", price: 1299.99, sku: "LAP-001", category: :electronics}, %{name: "Mouse", price: 29.99, sku: "MOU-001", category: :accessories}, %{name: "Monitor", price: 899.99, sku: "MON-001", category: :premium}]
[%{category: :electronics, name: "Laptop", price: 1299.99, sku: "LAP-001"}, %{category: :accessories, name: "Mouse", price: 29.99, sku: "MOU-001"}, %{category: :premium, name: "Monitor", price: 899.99, sku: "MON-001"}]

iex> MapCollections.get_names(products)
["Laptop", "Mouse", "Monitor"]

iex> MapCollections.get_expensive_skus(products)
["LAP-001"]

iex> MapCollections.find_premium_product(products)
%{category: :premium, name: "Monitor", price: 899.99, sku: "MON-001"}
Enter fullscreen mode Exit fullscreen mode

Working with Nested Maps

Nested maps are common when dealing with complex data structures, APIs, and configurations:

Creating Nested Maps

# Server configuration with nested monitoring and security
server_config = %{
  id: "SRV-001",
  hostname: "api.service.com",
  region: "us-east-1",
  networking: %{
    port: 443,
    protocol: "https",
    load_balancer: "ALB-001",
    coordinates: %{datacenter: "DC-1", rack: "R-15"}
  },
  monitoring: %{
    alerts: "enabled",
    metrics: %{
      cpu: true,
      memory: true,
      disk: false
    },
    retention: "30d"
  }
}

# API response with nested data
api_response = %{
  status: "success",
  data: %{
    sensors: [
      %{id: "TEMP-001", type: "temperature"},
      %{id: "HUMID-002", type: "humidity"}
    ],
    metadata: %{
      total: 2,
      page: 1,
      per_page: 10
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Accessing Nested Values

defmodule NestedAccess do
  # Safe nested access
  def get_user_city(user) do
    user
    |> Map.get(:address, %{})
    |> Map.get(:city)
  end

  # Deep access with get_in
  def get_notification_preference(user, type) do
    get_in(user, [:preferences, :notifications, type])
  end

  # Pattern matching for nested extraction
  def extract_coordinates(%{address: %{coordinates: coords}}) do
    {:ok, coords}
  end
  def extract_coordinates(_), do: {:error, :no_coordinates}

  # Multiple level extraction
  def get_contact_info(user) do
    with {:ok, name} <- Map.fetch(user, :name),
         {:ok, email} <- Map.fetch(user, :email),
         city <- get_user_city(user) do
      %{name: name, email: email, city: city}
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> user_profile = %{name: "Sofia", address: %{city: "Barcelona", state: "Catalonia"}, preferences: %{notifications: %{email: true, sms: false}}}
%{address: %{city: "Barcelona", state: "Catalonia"}, name: "Sofia", preferences: %{notifications: %{email: true, sms: false}}}

iex> NestedAccess.get_user_city(user_profile)
"Barcelona"

iex> NestedAccess.get_notification_preference(user_profile, :email)
true

iex> get_in(user_profile, [:preferences, :notifications, :sms])
false

iex> get_in(user_profile, [:preferences, :notifications, :push])
nil
Enter fullscreen mode Exit fullscreen mode

Updating Nested Maps

defmodule NestedUpdates do
  # Update nested value with put_in
  def update_city(user, new_city) do
    put_in(user, [:address, :city], new_city)
  end

  # Update with function using update_in
  def increment_login_count(user) do
    update_in(user, [:stats, :login_count], fn
      nil -> 1
      count -> count + 1
    end)
  end

  # Safe nested update (create path if doesn't exist)
  def enable_notification(user, type) do
    user
    |> put_in([:preferences], Map.get(user, :preferences, %{}))
    |> put_in([:preferences, :notifications],
              get_in(user, [:preferences, :notifications]) || %{})
    |> put_in([:preferences, :notifications, type], true)
  end

  # Complex nested transformation
  def normalize_address(user) do
    update_in(user, [:address], fn address ->
      address
      |> Map.update(:city, "", &String.upcase/1)
      |> Map.update(:state, "", &String.upcase/1)
    end)
  end
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> user_data = %{name: "Marcus", address: %{city: "madrid", state: "madrid"}}
%{address: %{city: "madrid", state: "madrid"}, name: "Marcus"}

iex> NestedUpdates.update_city(user_data, "Barcelona")
%{address: %{city: "Barcelona", state: "madrid"}, name: "Marcus"}

iex> NestedUpdates.normalize_address(user_data)
%{address: %{city: "MADRID", state: "MADRID"}, name: "Marcus"}

iex> simple_user = %{name: "Elena"}
%{name: "Elena"}

iex> NestedUpdates.enable_notification(simple_user, :email)
%{name: "Elena", preferences: %{notifications: %{email: true}}}
Enter fullscreen mode Exit fullscreen mode

Complex Nested Operations

defmodule ComplexNested do
  # Merge nested maps intelligently
  def deep_merge(map1, map2) do
    Map.merge(map1, map2, fn _key, val1, val2 ->
      if is_map(val1) and is_map(val2) do
        deep_merge(val1, val2)
      else
        val2
      end
    end)
  end

  # Flatten nested structure
  def flatten_user(user) do
    base = Map.take(user, [:id, :name, :email])
    address = Map.get(user, :address, %{})

    base
    |> Map.put(:city, Map.get(address, :city))
    |> Map.put(:state, Map.get(address, :state))
    |> Map.put(:zip, Map.get(address, :zip))
  end

  # Transform nested collections
  def transform_nested_users(response) do
    update_in(response, [:data, :users], fn users ->
      Enum.map(users, fn user ->
        Map.update(user, :name, "", &String.upcase/1)
      end)
    end)
  end
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> map1 = %{a: 1, b: %{c: 2, d: 3}}
%{a: 1, b: %{c: 2, d: 3}}

iex> map2 = %{a: 10, b: %{d: 30, e: 40}}
%{a: 10, b: %{d: 30, e: 40}}

iex> ComplexNested.deep_merge(map1, map2)
%{a: 10, b: %{c: 2, d: 30, e: 40}}

iex> user_with_address = %{id: "USR-001", name: "Viktor", email: "[email protected]", address: %{city: "Stockholm", state: "Stockholm", zip: "11122"}}
%{address: %{city: "Stockholm", state: "Stockholm", zip: "11122"}, email: "[email protected]", id: "USR-001", name: "Viktor"}

iex> ComplexNested.flatten_user(user_with_address)
%{city: "Stockholm", email: "[email protected]", id: "USR-001", name: "Viktor", state: "Stockholm", zip: "11122"}
Enter fullscreen mode Exit fullscreen mode

Maps vs Other Data Structures

Understanding when to choose maps over other data structures is crucial for writing idiomatic Elixir:

Maps vs Keyword Lists

# Use keyword lists for:
options = [host: "localhost", port: 4000, ssl: true]  # Function options
config = [debug: true, log_level: :info]              # Small configs
ecto_query = from(u in User, where: u.age > 18)       # DSL parameters

# Use maps for:
employee_data = %{id: 1001, role: "developer", department: "engineering"}  # Structured data
cache = %{"session:1" => %{status: "active"}, "session:2" => %{status: "expired"}} # Fast lookups
response = %{status: :ok, data: [...], message: "Success"}          # Complex data

# Conversion between them
keyword_to_map = Enum.into(options, %{})    # %{host: "localhost", port: 4000, ssl: true}
map_to_keyword = Enum.into(user_data, [])   # Not recommended - loses map benefits
Enter fullscreen mode Exit fullscreen mode

Maps vs Tuples

# Use tuples for:
coordinate = {10, 20}                    # Fixed position data
result = {:ok, "success"}                # Tagged return values
version = {1, 2, 3}                      # Fixed number of elements

# Use maps for:
point = %{x: 10, y: 20}                  # Named fields
api_result = %{status: :ok, data: "success", meta: %{}}  # Flexible structure
task = %{id: 42, title: "Deploy API", priority: :urgent}  # Record-like data

# When you need to add/remove fields, maps are better:
extended_point = Map.put(point, :z, 30)  # Easy to extend
# Can't easily extend a tuple: {10, 20} → {10, 20, 30} requires full reconstruction
Enter fullscreen mode Exit fullscreen mode

Maps vs Structs

# Maps: Flexible, dynamic structure
product_map = %{sku: "LAP-001", price: 1299.99, category: "electronics"}
extended_product = Map.put(product_map, :warranty, "2 years")  # Easy to extend

# Structs: Fixed structure, type safety
defmodule Product do
  defstruct [:sku, :price, :category]
end

product_struct = %Product{sku: "LAP-001", price: 1299.99, category: "electronics"}
# product_struct.warranty  # This would raise an error - warranty not defined

# Choose maps when:
# - Working with external data (JSON, APIs)
# - Structure might change
# - Need flexible key-value storage

# Choose structs when:
# - Modeling ___domain entities
# - Want compile-time guarantees
# - Need pattern matching on type
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> options = [host: "localhost", port: 4000]
[host: "localhost", port: 4000]

iex> Enum.into(options, %{})
%{host: "localhost", port: 4000}

iex> point_tuple = {10, 20}
{10, 20}

iex> point_map = %{x: 10, y: 20}
%{x: 10, y: 20}

iex> Map.put(point_map, :z, 30)
%{x: 10, y: 20, z: 30}
Enter fullscreen mode Exit fullscreen mode

Real World Applications

API Response Handling

defmodule APIHandler do
  # Process weather API response
  def process_weather_data(response) do
    case response do
      %{"temperature" => temp, "humidity" => humidity, "city" => city} ->
        {:ok, %{
          temperature: temp,
          humidity: humidity,
          ___location: city,
          wind_speed: Map.get(response, "wind_speed", 0),
          pressure: Map.get(response, "pressure", 0)
        }}

      %{"error" => "City not found"} ->
        {:error, :location_not_found}

      _ ->
        {:error, :invalid_response}
    end
  end

  # Handle paginated API responses
  def extract_paginated_data(%{"data" => items, "pagination" => pagination}) do
    %{
      items: items,
      current_page: Map.get(pagination, "page", 1),
      total_pages: Map.get(pagination, "pages", 1),
      total_items: Map.get(pagination, "total", 0),
      has_next: Map.get(pagination, "has_next", false)
    }
  end
end
Enter fullscreen mode Exit fullscreen mode

Configuration Management

defmodule AppConfig do
  # Application configuration with defaults
  def load_config(env \\ :dev) do
    base_config = %{
      app_name: "MyApp",
      port: 4000,
      database: %{
        adapter: "postgres",
        pool_size: 10
      },
      features: %{
        analytics: false,
        caching: true
      }
    }

    env_config = load_env_config(env)
    deep_merge_config(base_config, env_config)
  end

  # Environment-specific overrides
  defp load_env_config(:prod) do
    %{
      port: 80,
      database: %{
        pool_size: 20,
        timeout: 30_000
      },
      features: %{
        analytics: true,
        caching: true
      }
    }
  end

  defp load_env_config(:test) do
    %{
      database: %{
        adapter: "sqlite",
        database: ":memory:"
      },
      features: %{
        analytics: false,
        caching: false
      }
    }
  end

  defp load_env_config(_), do: %{}

  defp deep_merge_config(base, override) do
    Map.merge(base, override, fn _key, base_val, override_val ->
      if is_map(base_val) and is_map(override_val) do
        deep_merge_config(base_val, override_val)
      else
        override_val
      end
    end)
  end
end
Enter fullscreen mode Exit fullscreen mode

Data Transformation Pipeline

defmodule DataPipeline do
  # Transform raw CSV data into structured format
  def process_user_csv(csv_rows) do
    csv_rows
    |> Enum.map(&parse_csv_row/1)
    |> Enum.filter(&valid_user?/1)
    |> Enum.map(&normalize_user/1)
    |> Enum.group_by(& &1.department)
  end

  defp parse_csv_row([name, email, age, department, salary]) do
    %{
      name: String.trim(name),
      email: String.downcase(String.trim(email)),
      age: String.to_integer(age),
      department: String.trim(department),
      salary: String.to_float(salary)
    }
  end

  defp valid_user?(%{email: email, age: age}) do
    String.contains?(email, "@") and age >= 18
  end

  defp normalize_user(%{name: name, department: dept} = user) do
    %{user |
      name: String.split(name) |> Enum.map(&String.capitalize/1) |> Enum.join(" "),
      department: String.downcase(dept)
    }
  end

  # Build user analytics
  def calculate_department_stats(users_by_dept) do
    Map.new(users_by_dept, fn {dept, users} ->
      stats = %{
        count: length(users),
        avg_age: average_age(users),
        avg_salary: average_salary(users),
        age_range: age_range(users)
      }
      {dept, stats}
    end)
  end

  defp average_age(users) do
    (users |> Enum.map(& &1.age) |> Enum.sum()) / length(users)
  end

  defp average_salary(users) do
    (users |> Enum.map(& &1.salary) |> Enum.sum()) / length(users)
  end

  defp age_range(users) do
    ages = Enum.map(users, & &1.age)
    %{min: Enum.min(ages), max: Enum.max(ages)}
  end
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> weather_response = %{"temperature" => 23.5, "humidity" => 65, "city" => "Barcelona", "wind_speed" => 12.3}
%{"city" => "Barcelona", "humidity" => 65, "temperature" => 23.5, "wind_speed" => 12.3}

iex> APIHandler.process_weather_data(weather_response)
{:ok, %{humidity: 65, ___location: "Barcelona", pressure: 0, temperature: 23.5, wind_speed: 12.3}}

iex> AppConfig.load_config(:prod)
%{app_name: "MyApp", database: %{adapter: "postgres", pool_size: 20, timeout: 30000}, features: %{analytics: true, caching: true}, port: 80}
Enter fullscreen mode Exit fullscreen mode

Best Practices

Do's and Don'ts

✅ DO: Use atom keys for internal data

# Good - fast access, clear intent
employee = %{name: "Sofia", experience: 5, role: :lead}
Enter fullscreen mode Exit fullscreen mode

✅ DO: Use string keys for external data

# Good - matches JSON APIs, external sources
api_data = %{"user_id" => 123, "status" => "active"}
Enter fullscreen mode Exit fullscreen mode

✅ DO: Pattern match to extract needed data

# Good - clear and safe
def get_user_email(%{email: email}), do: {:ok, email}
def get_user_email(_), do: {:error, :no_email}
Enter fullscreen mode Exit fullscreen mode

✅ DO: Use Map.get with defaults for optional values

# Good - safe with sensible defaults
display_name = Map.get(user, :display_name, user.name)
Enter fullscreen mode Exit fullscreen mode

❌ DON'T: Mix atom and string keys unnecessarily

# Confusing - pick one style
mixed = %{status: :active, "priority" => "high"}
Enter fullscreen mode Exit fullscreen mode

❌ DON'T: Use maps for ordered data

# Bad - maps don't guarantee order
sequence = %{1 => "first", 2 => "second", 3 => "third"}

# Good - use lists for ordered data
sequence = ["first", "second", "third"]
Enter fullscreen mode Exit fullscreen mode

Performance Considerations

defmodule PerformanceTips do
  # Fast: Direct key access
  def get_name_fast(%{name: name}), do: name

  # Slower: Function call
  def get_name_slow(user), do: Map.get(user, :name)

  # Fast: Update existing key
  def update_age_fast(user, new_age), do: %{user | age: new_age}

  # Slower: Map.put (but works for new keys too)
  def update_age_slower(user, new_age), do: Map.put(user, :age, new_age)

  # Good: Batch updates
  def update_user_info(user, updates) do
    Map.merge(user, updates)
  end

  # Less efficient: Individual updates
  def update_user_info_slow(user, name, age, email) do
    user
    |> Map.put(:name, name)
    |> Map.put(:age, age)
    |> Map.put(:email, email)
  end
end
Enter fullscreen mode Exit fullscreen mode

Error Handling Patterns

defmodule SafeMapOperations do
  # Safe extraction with defaults
  def safe_get(map, key, default \\ nil) do
    Map.get(map, key, default)
  end

  # Validation before processing
  def process_user(user_data) do
    with {:ok, user} <- validate_required_fields(user_data),
         {:ok, normalized} <- normalize_user_data(user) do
      {:ok, normalized}
    end
  end

  defp validate_required_fields(data) do
    required = [:name, :email, :age]

    case Enum.find(required, &(!Map.has_key?(data, &1))) do
      nil -> {:ok, data}
      missing_key -> {:error, {:missing_field, missing_key}}
    end
  end

  defp normalize_user_data(user) do
    try do
      normalized = %{
        name: String.trim(user.name),
        email: String.downcase(user.email),
        age: ensure_integer(user.age)
      }
      {:ok, normalized}
    rescue
      _ -> {:error, :normalization_failed}
    end
  end

  defp ensure_integer(value) when is_integer(value), do: value
  defp ensure_integer(value) when is_binary(value), do: String.to_integer(value)
  defp ensure_integer(_), do: raise("Invalid integer value")
end
Enter fullscreen mode Exit fullscreen mode

Testing in IEx:

iex> good_data = %{name: " Elena Martinez ", email: "[email protected]", age: "28"}
%{age: "28", email: "[email protected]", name: " Elena Martinez "}

iex> SafeMapOperations.process_user(good_data)
{:ok, %{age: 28, email: "[email protected]", name: "Elena Martinez"}}

iex> bad_data = %{name: "Viktor", email: "[email protected]"}
%{email: "[email protected]", name: "Viktor"}

iex> SafeMapOperations.process_user(bad_data)
{:error, {:missing_field, :age}}
Enter fullscreen mode Exit fullscreen mode

Conclusion

Maps are one of the most versatile and powerful data structures in Elixir, providing the foundation for modeling complex data in functional applications. In this article, we've explored:

  • How maps work internally with key-value pairs
  • Creating and updating maps with various techniques
  • Accessing values safely with different approaches
  • Pattern matching for powerful data extraction
  • Essential map functions for transformation and manipulation
  • Working with nested maps for complex data structures
  • Choosing between maps and other data structures
  • Real-world applications in APIs, configuration, and data processing
  • Best practices for performance and error handling

Key takeaways:

  • Maps excel at key-value storage with fast lookups
  • Use atom keys for internal data, string keys for external data
  • Pattern matching makes map code elegant and safe
  • Nested maps handle complex hierarchical data well
  • Choose maps for flexible, dynamic data structures
  • Combine maps with other data structures for optimal solutions
  • Always consider immutability when updating maps

Maps form the backbone of many Elixir applications, from web servers handling JSON to configuration systems managing complex settings. Master maps, and you'll have the tools to model almost any kind of data your applications need.

Further Reading

Next Steps

With a solid understanding of maps, you're ready to explore Keyword Lists in Elixir. Keyword lists provide an ordered collection of key-value pairs that's perfect for function options, small configurations, and DSL parameters.

In the next article, we'll explore:

  • Creating and manipulating keyword lists
  • When to choose keyword lists over maps
  • Pattern matching with keyword lists
  • Function options and configuration patterns
  • Working with duplicate keys
  • Performance characteristics and trade-offs

Keyword lists complete our journey through Elixir's core data structures, giving you all the tools needed to choose the right structure for any situation!

Top comments (0)