Learning Elixir: Keyword Lists

Keyword lists are like restaurant order forms where each field has a clear label and you can repeat certain items when it makes sense. Unlike a table setting where each position holds one specific item, an order form lets you write “drink: water, appet…


This content originally appeared on DEV Community and was authored by João Paulo Abreu

Keyword lists are like restaurant order forms where each field has a clear label and you can repeat certain items when it makes sense. Unlike a table setting where each position holds one specific item, an order form lets you write "drink: water, appetizer: salad, drink: soda" - keeping everything in order and allowing duplicates when needed. Think of [timeout: 5000, retry: 3, retry: 5] as a configuration form where some options can be specified multiple times, each with its own meaning and context. While this flexibility makes keyword lists perfect for function options and small configurations, it also makes them quite different from maps in both behavior and use cases. In this comprehensive article, we'll explore how keyword lists work, when they're the perfect choice for your data, and the patterns that make them indispensable in Elixir's ecosystem.

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
  • Understanding Keyword Lists
  • Creating and Manipulating Keyword Lists
  • Function Options and Configuration Patterns
  • Working with Duplicate Keys
  • Pattern Matching with Keyword Lists
  • Keyword Lists vs Maps
  • Best Practices
  • Conclusion
  • Further Reading
  • Next Steps

Introduction

Keyword lists are Elixir's specialized data structure for handling small collections of key-value pairs where order matters and duplicate keys provide semantic value. They serve as the backbone for function options, configuration systems, and DSL parameters throughout the Elixir ecosystem.

What makes keyword lists special:

  • Ordered collection: Keys maintain their insertion order
  • Duplicate keys allowed: Same key can appear multiple times
  • Atom keys only: Keys must be atoms (not strings or other types)
  • Concise syntax: Clean [key: value] notation
  • Function-friendly: Perfect for optional parameters
  • DSL integration: Essential for domain-specific languages

Think of keyword lists as configuration forms where each setting has a name:

  • Function options: [timeout: 5000, retries: 3, async: true]
  • Query parameters: [where: "active = true", limit: 10, order: "name"]
  • Route options: [as: :user_profile, only: [:show, :edit]]

Keyword lists excel when you need to:

  • Pass options to functions with readable names
  • Maintain the order of configuration items
  • Allow certain options to be specified multiple times
  • Create clean, readable APIs for your modules
  • Build domain-specific languages and configuration systems

Let's explore how they work and why they're indispensable!

Understanding Keyword Lists

The Internal Structure

Keyword lists are actually lists of two-element tuples where the first element is always an atom:

# These are equivalent:
options_short = [timeout: 5000, retry: 3]
options_verbose = [{:timeout, 5000}, {:retry, 3}]

# Verify they're the same
options_short == options_verbose  # true

# Under the hood, it's just a list
[timeout: 5000, retry: 3] |> is_list()  # true

Testing in IEx:

iex> [timeout: 5000, retry: 3]
[timeout: 5000, retry: 3]

iex> [{:timeout, 5000}, {:retry, 3}]
[timeout: 5000, retry: 3]

iex> [timeout: 5000, retry: 3] == [{:timeout, 5000}, {:retry, 3}]
true

iex> is_list([timeout: 5000, retry: 3])
true

Key Characteristics

config = [host: "localhost", port: 4000, host: "remote", ssl: true]

# Order is preserved
Keyword.keys(config)  # [:host, :port, :host, :ssl]

# Duplicate keys are allowed
Keyword.get_values(config, :host)  # ["localhost", "remote"]

# First occurrence is returned by default
Keyword.get(config, :host)  # "localhost"

# Only atoms can be keys in keyword lists
# [{1, "value"}]  # Valid syntax but not a keyword list (integer key)
# [{"key", "value"}]  # Valid syntax but not a keyword list (string key)

Testing in IEx:

iex> config = [host: "localhost", port: 4000, host: "remote", ssl: true]
[host: "localhost", port: 4000, host: "remote", ssl: true]

iex> Keyword.keys(config)
[:host, :port, :host, :ssl]

iex> Keyword.get_values(config, :host)
["localhost", "remote"]

iex> Keyword.get(config, :host)
"localhost"

Syntax Variations

# Standard syntax (recommended)
options = [timeout: 5000, async: true]

# Verbose tuple syntax
options_verbose = [{:timeout, 5000}, {:async, true}]

# Mixed syntax (keyword lists must come last)
mixed = [{:timeout, 5000}, async: true]

# With variables as keys
key = :retry
options_with_var = [{key, 3}, timeout: 5000]  # [retry: 3, timeout: 5000]

# Multi-line for readability
long_config = [
  host: "api.example.com",
  port: 443,
  timeout: 10_000,
  retries: 5,
  ssl: true
]

Testing in IEx:

iex> key = :retry
:retry

iex> options = [{key, 3}, timeout: 5000]
[retry: 3, timeout: 5000]

iex> Keyword.keyword?(options)
true

iex> Keyword.keyword?(%{retry: 3, timeout: 5000})
false

Creating and Manipulating Keyword Lists

Creation Methods

# Literal syntax (most common)
config = [host: "localhost", port: 4000, ssl: true]

# From tuples
from_tuples = Keyword.new([{:timeout, 5000}, {:retries, 3}])

# From maps (loses duplicate key capability)
from_map = Keyword.new(%{timeout: 5000, retries: 3})

# From other enumerables
from_list = Keyword.new([timeout: 5000, retries: 3])

# Empty keyword list
empty = Keyword.new()  # []

# Building incrementally
incremental = []
  |> Keyword.put(:host, "localhost")
  |> Keyword.put(:port, 4000)
  |> Keyword.put(:ssl, true)

Testing in IEx:

iex> Keyword.new([{:timeout, 5000}, {:retries, 3}])
[timeout: 5000, retries: 3]

iex> Keyword.new(%{timeout: 5000, retries: 3})
[timeout: 5000, retries: 3]

iex> [] |> Keyword.put(:host, "localhost") |> Keyword.put(:port, 4000)
[port: 4000, host: "localhost"]

Basic Operations

options = [timeout: 5000, retries: 3, host: "localhost"]

# Get values
timeout = Keyword.get(options, :timeout)        # 5000
missing = Keyword.get(options, :missing, "N/A") # "N/A"
port = Keyword.get(options, :port, 4000)        # 4000 (default)

# Check for keys
has_timeout = Keyword.has_key?(options, :timeout)  # true
has_port = Keyword.has_key?(options, :port)        # false

# Get all keys and values
keys = Keyword.keys(options)      # [:timeout, :retries, :host]
values = Keyword.values(options)  # [5000, 3, "localhost"]

# Check if it's a keyword list and get length
is_keyword = Keyword.keyword?(options)  # true
length = length(options)                # 3

Testing in IEx:

iex> options = [timeout: 5000, retries: 3, host: "localhost"]
[timeout: 5000, retries: 3, host: "localhost"]

iex> Keyword.get(options, :timeout)
5000

iex> Keyword.get(options, :port, 4000)
4000

iex> Keyword.has_key?(options, :timeout)
true

iex> Keyword.keys(options)
[:timeout, :retries, :host]

Updating and Modifying

original = [timeout: 5000, retries: 3]

# Add new key-value pair
with_ssl = Keyword.put(original, :ssl, true)
# [ssl: true, timeout: 5000, retries: 3]

# Update existing key (replaces first occurrence)
updated_timeout = Keyword.put(original, :timeout, 8000)
# [timeout: 8000, retries: 3]

# Add only if key doesn't exist
maybe_added = Keyword.put_new(original, :host, "localhost")
# [host: "localhost", timeout: 5000, retries: 3]

wont_change = Keyword.put_new(original, :timeout, 8000)
# [timeout: 5000, retries: 3] (timeout already exists)

# Delete keys
without_retries = Keyword.delete(original, :retries)
# [timeout: 5000]

# Merge keyword lists
defaults = [timeout: 1000, retries: 1, ssl: false]
user_config = [timeout: 5000, host: "remote"]
merged = Keyword.merge(defaults, user_config)
# [timeout: 5000, retries: 1, ssl: false, host: "remote"]

Testing in IEx:

iex> original = [timeout: 5000, retries: 3]
[timeout: 5000, retries: 3]

iex> Keyword.put(original, :ssl, true)
[ssl: true, timeout: 5000, retries: 3]

iex> Keyword.put_new(original, :timeout, 8000)
[timeout: 5000, retries: 3]

iex> Keyword.put_new(original, :host, "localhost")
[host: "localhost", timeout: 5000, retries: 3]

iex> defaults = [timeout: 1000, retries: 1, ssl: false]
[timeout: 1000, retries: 1, ssl: false]

iex> user_config = [timeout: 5000, host: "remote"]
[timeout: 5000, host: "remote"]

iex> Keyword.merge(defaults, user_config)
[retries: 1, ssl: false, timeout: 5000, host: "remote"]

Conversion Operations

# Keyword list to map
kw_list = [name: "Alice", age: 30, role: :admin]
as_map = Enum.into(kw_list, %{})
# %{age: 30, name: "Alice", role: :admin}

# Map to keyword list
map = %{timeout: 5000, retries: 3}
as_keyword = Enum.into(map, [])
# [timeout: 5000, retries: 3] (order may vary)

# Filter keyword list
config = [timeout: 5000, retries: 3, debug: true, verbose: false]
only_booleans = Keyword.take(config, [:debug, :verbose])
# [debug: true, verbose: false]

without_debug = Keyword.drop(config, [:debug, :verbose])
# [timeout: 5000, retries: 3]

Testing in IEx:

iex> kw_list = [name: "Alice", age: 30, role: :admin]
[name: "Alice", age: 30, role: :admin]

iex> Enum.into(kw_list, %{})
%{age: 30, name: "Alice", role: :admin}

iex> map = %{timeout: 5000, retries: 3}
%{retries: 3, timeout: 5000}

iex> Enum.into(map, [])
[timeout: 5000, retries: 3]

iex> config = [timeout: 5000, retries: 3, debug: true, verbose: false]
[timeout: 5000, retries: 3, debug: true, verbose: false]

iex> Keyword.take(config, [:debug, :verbose])
[debug: true, verbose: false]

Function Options and Configuration Patterns

The Idiomatic Elixir Pattern

Functions that accept options typically use keyword lists as the last parameter:

defmodule HTTPClient do
  # Function with default options
  def get(url, opts \\ []) do
    timeout = Keyword.get(opts, :timeout, 5000)
    retries = Keyword.get(opts, :retries, 3)
    _headers = Keyword.get(opts, :headers, [])

    "Requesting #{url} with timeout: #{timeout}ms, retries: #{retries}"
  end

  # More complex option processing
  def post(url, _body, opts \\ []) do
    config = build_config(opts)
    "POST to #{url} with config: #{inspect(config)}"
  end

  defp build_config(opts) do
    %{
      timeout: Keyword.get(opts, :timeout, 5000),
      retries: Keyword.get(opts, :retries, 3),
      headers: Keyword.get(opts, :headers, []),
      follow_redirects: Keyword.get(opts, :follow_redirects, true),
      ssl_verify: Keyword.get(opts, :ssl_verify, true)
    }
  end
end

Testing in IEx:

iex> HTTPClient.get("https://api.example.com")
"Requesting https://api.example.com with timeout: 5000ms, retries: 3"

iex> HTTPClient.get("https://api.example.com", timeout: 1000, retries: 5)
"Requesting https://api.example.com with timeout: 1000ms, retries: 5"

iex> HTTPClient.post("https://api.example.com", "data", ssl_verify: false)
"POST to https://api.example.com with config: %{follow_redirects: true, headers: [], retries: 3, ssl_verify: false, timeout: 5000}"

Option Validation Patterns

defmodule SafeProcessor do
  @valid_options [:timeout, :retries, :async, :callback]
  @required_options [:timeout]

  def process(_data, opts \\ []) do
    with :ok <- validate_options(opts),
         config <- build_config(opts) do
      {:ok, "Processing with config: #{inspect(config)}"}
    else
      {:error, reason} -> {:error, reason}
    end
  end

  defp validate_options(opts) do
    case validate_required(opts) do
      :ok -> validate_allowed(opts)
      error -> error
    end
  end

  defp validate_required(opts) do
    missing = @required_options -- Keyword.keys(opts)
    if missing == [], do: :ok, else: {:error, {:missing_options, missing}}
  end

  defp validate_allowed(opts) do
    invalid = Keyword.keys(opts) -- @valid_options
    if invalid == [], do: :ok, else: {:error, {:invalid_options, invalid}}
  end

  defp build_config(opts) do
    %{
      timeout: Keyword.get(opts, :timeout),
      retries: Keyword.get(opts, :retries, 3),
      async: Keyword.get(opts, :async, false),
      callback: Keyword.get(opts, :callback)
    }
  end
end

Testing in IEx:

iex> SafeProcessor.process("data", timeout: 5000, retries: 2)
{:ok, "Processing with config: %{async: false, callback: nil, retries: 2, timeout: 5000}"}

iex> SafeProcessor.process("data", [])
{:error, {:missing_options, [:timeout]}}

iex> SafeProcessor.process("data", timeout: 5000, invalid_option: true)
{:error, {:invalid_options, [:invalid_option]}}

Configuration Management

defmodule AppConfig do
  @default_config [
    host: "localhost",
    port: 4000,
    pool_size: 10,
    timeout: 15_000,
    ssl: false,
    retries: 3
  ]

  def load(env_config \\ []) do
    @default_config
    |> Keyword.merge(env_config)
    |> validate_config()
  end

  def get_database_config(config) do
    config
    |> Keyword.take([:host, :port, :pool_size, :ssl])
    |> add_database_specific_options()
  end

  def get_http_config(config) do
    config
    |> Keyword.take([:host, :port, :timeout, :retries, :ssl])
  end

  defp add_database_specific_options(config) do
    config
    |> Keyword.put_new(:username, "postgres")
    |> Keyword.put_new(:password, "")
    |> Keyword.put_new(:database, "myapp_dev")
  end

  defp validate_config(config) do
    cond do
      config[:port] < 1 or config[:port] > 65535 ->
        {:error, "Invalid port number"}
      config[:pool_size] < 1 ->
        {:error, "Pool size must be positive"}
      true ->
        {:ok, config}
    end
  end
end

Testing in IEx:

iex> AppConfig.load()
{:ok, [host: "localhost", port: 4000, pool_size: 10, timeout: 15000, ssl: false, retries: 3]}

iex> AppConfig.load(port: 8080, ssl: true)
{:ok, [host: "localhost", port: 8080, pool_size: 10, timeout: 15000, ssl: true, retries: 3]}

iex> {:ok, config} = AppConfig.load()
{:ok, [host: "localhost", port: 4000, pool_size: 10, timeout: 15000, ssl: false, retries: 3]}

iex> AppConfig.get_database_config(config)
[host: "localhost", port: 4000, pool_size: 10, ssl: false, username: "postgres", password: "", database: "myapp_dev"]

Documentation Patterns

defmodule DocumentedModule do
  @doc """
  Processes data with configurable options.

  ## Options

    * `:timeout` - Request timeout in milliseconds (default: 5000)
    * `:retries` - Number of retry attempts (default: 3)
    * `:async` - Process asynchronously (default: false)
    * `:callback` - Function called on completion (default: nil)
    * `:format` - Output format, either `:json` or `:text` (default: `:json`)

  ## Examples

      iex> DocumentedModule.process("data")
      {:ok, "Processing data with default options"}

      iex> DocumentedModule.process("data", timeout: 1000, async: true)
      {:ok, "Processing data asynchronously with 1000ms timeout"}

  """
  def process(data, opts \\ []) do
    timeout = Keyword.get(opts, :timeout, 5000)
    async = Keyword.get(opts, :async, false)

    async_text = if async, do: "asynchronously ", else: ""
    {:ok, "Processing #{data} #{async_text}with #{timeout}ms timeout"}
  end
end

Testing in IEx:

iex> DocumentedModule.process("data")
{:ok, "Processing data with 5000ms timeout"}

iex> DocumentedModule.process("data", timeout: 1000, async: true)
{:ok, "Processing data asynchronously with 1000ms timeout"}

Working with Duplicate Keys

Understanding Duplicate Key Behavior

config = [env: :dev, timeout: 5000, env: :prod, retry: 3, env: :test]

# get/2 returns the FIRST occurrence
primary_env = Keyword.get(config, :env)  # :dev

# get_values/2 returns ALL occurrences
all_envs = Keyword.get_values(config, :env)  # [:dev, :prod, :test]

# has_key?/2 returns true if ANY occurrence exists
has_env = Keyword.has_key?(config, :env)  # true

# delete/2 removes ALL occurrences
no_envs = Keyword.delete(config, :env)
# [timeout: 5000, retry: 3]

Testing in IEx:

iex> config = [env: :dev, timeout: 5000, env: :prod, retry: 3, env: :test]
[env: :dev, timeout: 5000, env: :prod, retry: 3, env: :test]

iex> Keyword.get(config, :env)
:dev

iex> Keyword.get_values(config, :env)
[:dev, :prod, :test]

iex> Keyword.delete(config, :env)
[timeout: 5000, retry: 3]

Practical Use Cases for Duplicates

defmodule PlugPipeline do
  # Multiple middleware configurations
  def build_pipeline(opts) do
    middleware_configs = Keyword.get_values(opts, :middleware)
    auth_configs = Keyword.get_values(opts, :auth)

    %{
      middleware: middleware_configs,
      auth: auth_configs,
      timeout: Keyword.get(opts, :timeout, 30_000)
    }
  end

  def example_usage do
    options = [
      middleware: {:cors, origins: ["localhost"]},
      middleware: {:rate_limit, max: 100},
      middleware: {:logging, level: :info},
      auth: {:token, header: "Authorization"},
      auth: {:session, store: :cookie},
      timeout: 5000
    ]

    build_pipeline(options)
  end
end

Testing in IEx:

iex> PlugPipeline.example_usage()
%{
  auth: [token: [header: "Authorization"], session: [store: :cookie]],
  middleware: [
    cors: [origins: ["localhost"]],
    rate_limit: [max: 100],
    logging: [level: :info]
  ],
  timeout: 5000
}

Advanced Duplicate Key Patterns

defmodule QueryBuilder do
  def build_query(clauses) do
    %{
      select: build_select_clause(clauses),
      where: build_where_clauses(clauses),
      join: build_join_clauses(clauses),
      order: build_order_clauses(clauses)
    }
  end

  defp build_select_clause(clauses) do
    case Keyword.get_values(clauses, :select) do
      [] -> "*"
      fields -> Enum.join(fields, ", ")
    end
  end

  defp build_where_clauses(clauses) do
    clauses
    |> Keyword.get_values(:where)
    |> Enum.join(" AND ")
  end

  defp build_join_clauses(clauses) do
    Keyword.get_values(clauses, :join)
  end

  defp build_order_clauses(clauses) do
    clauses
    |> Keyword.get_values(:order)
    |> Enum.join(", ")
  end

  def example_query do
    clauses = [
      select: "name",
      select: "email",
      select: "age",
      where: "active = true",
      where: "age >= 18",
      join: "LEFT JOIN profiles ON users.id = profiles.user_id",
      order: "name ASC",
      order: "created_at DESC"
    ]

    build_query(clauses)
  end
end

Testing in IEx:

iex> QueryBuilder.example_query()
%{
  join: ["LEFT JOIN profiles ON users.id = profiles.user_id"],
  order: "name ASC, created_at DESC",
  select: "name, email, age",
  where: "active = true AND age >= 18"
}

Handling Duplicates Safely

defmodule DuplicateHandler do
  # Get first occurrence with fallback
  def get_first_or_default(keyword_list, key, default) do
    case Keyword.get_values(keyword_list, key) do
      [] -> default
      [first | _] -> first
    end
  end

  # Get last occurrence
  def get_last(keyword_list, key) do
    keyword_list
    |> Keyword.get_values(key)
    |> List.last()
  end

  # Remove specific occurrence by value
  def delete_value(keyword_list, key, value) do
    Enum.reject(keyword_list, fn {k, v} -> k == key and v == value end)
  end

  # Replace all occurrences with single value
  def replace_all(keyword_list, key, new_value) do
    keyword_list
    |> Keyword.delete(key)
    |> Keyword.put(key, new_value)
  end
end

Testing in IEx:

iex> config = [env: :dev, timeout: 5000, env: :prod, env: :test]
[env: :dev, timeout: 5000, env: :prod, env: :test]

iex> DuplicateHandler.get_last(config, :env)
:test

iex> DuplicateHandler.delete_value(config, :env, :prod)
[env: :dev, timeout: 5000, env: :test]

iex> DuplicateHandler.replace_all(config, :env, :staging)
[env: :staging, timeout: 5000]

Pattern Matching with Keyword Lists

The Official Warning

Important: The Elixir documentation explicitly advises against pattern matching directly on keyword lists. Here's why and what to do instead:

Why Avoid Direct Pattern Matching

defmodule AntiPatterns do
  # DON'T DO THIS - Fragile and order-dependent
  def bad_pattern_match([timeout: t, retries: r]) do
    "Timeout: #{t}, Retries: #{r}"
  end

  # This function will fail if:
  # - Keys are in different order: [retries: 3, timeout: 5000]
  # - Extra keys are present: [timeout: 5000, retries: 3, ssl: true]
  # - Keys are missing: [timeout: 5000]

  # DON'T DO THIS EITHER - Still fragile
  def bad_partial_match([timeout: t | _rest]) do
    "Timeout: #{t}"
  end
  # Fails if timeout is not the first key
end

Safe Alternatives Using Keyword Module

defmodule SafePatterns do
  # GOOD - Use Keyword.get/3 for extraction
  def safe_extraction(options) do
    timeout = Keyword.get(options, :timeout, 5000)
    retries = Keyword.get(options, :retries, 3)

    "Timeout: #{timeout}, Retries: #{retries}"
  end

  # GOOD - Validate structure first
  def process_with_validation(options) do
    with {:ok, timeout} <- fetch_timeout(options),
         {:ok, retries} <- fetch_retries(options) do
      {:ok, "Processing with timeout: #{timeout}, retries: #{retries}"}
    end
  end

  defp fetch_timeout(options) do
    case Keyword.fetch(options, :timeout) do
      {:ok, timeout} when is_integer(timeout) and timeout > 0 -> {:ok, timeout}
      {:ok, _} -> {:error, :invalid_timeout}
      :error -> {:error, :missing_timeout}
    end
  end

  defp fetch_retries(options) do
    case Keyword.fetch(options, :retries) do
      {:ok, retries} when is_integer(retries) and retries >= 0 -> {:ok, retries}
      {:ok, _} -> {:error, :invalid_retries}
      :error -> {:ok, 3}  # Default value
    end
  end

  # GOOD - Pattern match on known structure after validation
  def safe_pattern_match(options) when is_list(options) do
    case Keyword.keyword?(options) do
      true -> extract_safely(options)
      false -> {:error, :not_keyword_list}
    end
  end

  defp extract_safely(options) do
    host = Keyword.get(options, :host, "localhost")
    port = Keyword.get(options, :port, 4000)
    {:ok, "Connecting to #{host}:#{port}"}
  end
end

Testing in IEx:

iex> good_options = [retries: 3, timeout: 5000, ssl: true]
[retries: 3, timeout: 5000, ssl: true]

iex> SafePatterns.safe_extraction(good_options)
"Timeout: 5000, Retries: 3"

iex> SafePatterns.process_with_validation(good_options)
{:ok, "Processing with timeout: 5000, retries: 3"}

iex> bad_options = [timeout: -1000, retries: 3]
[timeout: -1000, retries: 3]

iex> SafePatterns.process_with_validation(bad_options)
{:error, :invalid_timeout}

When Pattern Matching Can Work

There are limited scenarios where pattern matching on keyword lists is acceptable:

defmodule LimitedPatternMatching do
  # ACCEPTABLE - Matching on empty list
  def handle_options([]), do: "No options provided"

  # ACCEPTABLE - Matching specific known structures in controlled contexts
  def handle_simple_config([debug: true]), do: "Debug mode enabled"
  def handle_simple_config([debug: false]), do: "Debug mode disabled"
  def handle_simple_config(other), do: handle_complex_config(other)

  # PREFERRED - Always have a catch-all that uses Keyword functions
  defp handle_complex_config(options) do
    debug = Keyword.get(options, :debug, false)
    "Debug mode: #{debug}, other options: #{inspect(Keyword.delete(options, :debug))}"
  end
end

Testing in IEx:

iex> LimitedPatternMatching.handle_options([])
"No options provided"

iex> LimitedPatternMatching.handle_simple_config([debug: true])
"Debug mode enabled"

iex> LimitedPatternMatching.handle_simple_config([debug: true, timeout: 5000])
"Debug mode: true, other options: [timeout: 5000]"

Safe Extraction Patterns

defmodule ExtractionPatterns do
  # Extract with defaults and validation
  def extract_database_config(options) do
    %{
      host: Keyword.get(options, :host, "localhost"),
      port: validate_port(Keyword.get(options, :port, 5432)),
      database: Keyword.fetch!(options, :database),
      username: Keyword.get(options, :username, "postgres"),
      password: Keyword.get(options, :password, ""),
      ssl: Keyword.get(options, :ssl, false)
    }
  rescue
    KeyError -> {:error, :missing_database_name}
  end

  # Multiple option sets handling
  def extract_environments(options) do
    options
    |> Keyword.get_values(:env)
    |> case do
      [] -> [:dev]  # Default environment
      envs -> envs
    end
  end

  # Conditional extraction
  def extract_auth_config(options) do
    case {Keyword.has_key?(options, :username), Keyword.has_key?(options, :token)} do
      {true, false} ->
        {:basic_auth, Keyword.get(options, :username), Keyword.get(options, :password, "")}
      {false, true} ->
        {:token_auth, Keyword.get(options, :token)}
      {false, false} ->
        :no_auth
      {true, true} ->
        {:error, :conflicting_auth_methods}
    end
  end

  defp validate_port(port) when is_integer(port) and port > 0 and port <= 65535, do: port
  defp validate_port(_), do: raise(ArgumentError, "Invalid port number")
end

Testing in IEx:

iex> db_options = [host: "db.example.com", database: "myapp", port: 5432]
[host: "db.example.com", database: "myapp", port: 5432]

iex> ExtractionPatterns.extract_database_config(db_options)
%{database: "myapp", host: "db.example.com", password: "", port: 5432, ssl: false, username: "postgres"}

iex> auth_options = [username: "admin", password: "secret"]
[username: "admin", password: "secret"]

iex> ExtractionPatterns.extract_auth_config(auth_options)
{:basic_auth, "admin", "secret"}

iex> token_options = [token: "abc123"]
[token: "abc123"]

iex> ExtractionPatterns.extract_auth_config(token_options)
{:token_auth, "abc123"}

Keyword Lists vs Maps

Decision Guide

Understanding when to use keyword lists versus maps is crucial for writing idiomatic Elixir:

Use Keyword Lists When:

# 1. Function options (the primary use case)
def api_call(endpoint, opts \\ []) do
  timeout = Keyword.get(opts, :timeout, 5000)
  headers = Keyword.get(opts, :headers, [])
  # Process with options...
end

# 2. Small configuration (typically < 50 keys)
config = [
  host: "localhost",
  port: 4000,
  ssl: false,
  retries: 3
]

# 3. DSL parameters where order matters
route_options = [as: :user_profile, only: [:show, :edit], except: [:delete]]

# 4. Duplicate keys provide semantic meaning
middleware_stack = [
  middleware: {:cors, origins: ["*"]},
  middleware: {:auth, required: true},
  middleware: {:logging, level: :info}
]

# 5. Interfacing with libraries that expect keyword lists
# Many Elixir libraries use keyword lists for options

Use Maps When:

# 1. Large datasets (50+ key-value pairs)
large_config = %{
  database_url: "...",
  redis_url: "...",
  # ... 50+ more configuration items
}

# 2. Large datasets and complex data structures
cache = %{
  "user:123" => %{name: "Alice"},
  "user:456" => %{name: "Bob"},
  # ... potentially thousands of entries
}

# 3. Data modeling and structured records
user = %{
  id: 123,
  name: "Alice",
  email: "alice@example.com",
  created_at: ~U[2024-01-15 10:00:00Z]
}

# 4. Pattern matching requirements
def handle_response(%{status: 200, body: body}), do: {:ok, body}
def handle_response(%{status: 404}), do: {:error, :not_found}

# 5. Unique keys required
settings = %{theme: "dark", language: "en"}  # No duplicate keys allowed

Conversion Strategies

defmodule ConversionStrategies do
  # Convert keyword list to map when it becomes large
  def optimize_for_lookups(options) when length(options) > 50 do
    Enum.into(options, %{})
  end
  def optimize_for_lookups(options), do: options

  # Maintain keyword list for small, option-like data
  def ensure_keyword_list(data) when is_map(data) and map_size(data) <= 20 do
    Enum.into(data, [])
  end
  def ensure_keyword_list(data) when is_list(data), do: data
  def ensure_keyword_list(data), do: data  # Keep as-is for other types

  # Smart conversion based on usage pattern
  def convert_for_usage(data, :large_dataset) when is_list(data) do
    Enum.into(data, %{})
  end
  def convert_for_usage(data, :function_options) when is_map(data) do
    Enum.into(data, [])
  end
  def convert_for_usage(data, _usage), do: data

  # Preserve duplicates when converting back to keyword list
  def map_to_keyword_preserving_order(map, key_order) do
    Enum.map(key_order, fn key -> {key, Map.get(map, key)} end)
  end
end

Testing in IEx:

iex> small_options = [timeout: 5000, retries: 3, ssl: true]
[timeout: 5000, retries: 3, ssl: true]

iex> ConversionStrategies.optimize_for_lookups(small_options)
[timeout: 5000, retries: 3, ssl: true]

iex> large_options = Enum.map(1..60, &{:"key#{&1}", &1})
[key1: 1, key2: 2, key3: 3, key4: 4, key5: 5, ...]

iex> optimized = ConversionStrategies.optimize_for_lookups(large_options)
%{key1: 1, key2: 2, key3: 3, key4: 4, key5: 5, ...}

iex> is_map(optimized)
true

Hybrid Approaches

defmodule HybridPatterns do
  # Use both structures for different purposes
  defstruct options: [], cache: %{}

  def new(options \\ []) do
    %__MODULE__{options: options, cache: build_cache(options)}
  end

  # Keep options as keyword list for API compatibility
  def get_option(%__MODULE__{options: options}, key, default) do
    Keyword.get(options, key, default)
  end

  # Use map for internal lookups
  defp build_cache(options) do
    options
    |> Enum.take(20)  # Cache only first 20 options
    |> Enum.into(%{})
  end

  # Extract frequently accessed options to map, keep others as keyword list
  def partition_options(options, frequent_keys) do
    {frequent, others} = Enum.split_with(options, fn {key, _} ->
      key in frequent_keys
    end)

    {Enum.into(frequent, %{}), others}
  end
end

Testing in IEx:

iex> config = HybridPatterns.new([timeout: 5000, retries: 3, ssl: true])
%HybridPatterns{cache: %{retries: 3, ssl: true, timeout: 5000}, options: [timeout: 5000, retries: 3, ssl: true]}

iex> HybridPatterns.get_option(config, :timeout, 1000)
5000

iex> options = [timeout: 5000, retries: 3, host: "localhost", ssl: true, debug: false]
[timeout: 5000, retries: 3, host: "localhost", ssl: true, debug: false]

iex> HybridPatterns.partition_options(options, [:timeout, :retries])
{%{retries: 3, timeout: 5000}, [host: "localhost", ssl: true, debug: false]}

Best Practices

Do's and Don'ts

✅ DO: Use keyword lists for function options

# Good - idiomatic Elixir pattern
def api_call(endpoint, opts \\ []) do
  timeout = Keyword.get(opts, :timeout, 5000)
  retries = Keyword.get(opts, :retries, 3)
  # Process with options
end

# Usage
api_call("/users", timeout: 1000, retries: 5)

✅ DO: Keep keyword lists small (< 50 keys)

# Good - small configuration
config = [
  host: "localhost",
  port: 4000,
  ssl: true,
  pool_size: 10
]

✅ DO: Use Keyword module functions for access

# Good - safe and flexible
def process_config(config) do
  host = Keyword.get(config, :host, "localhost")
  port = Keyword.get(config, :port, 4000)
  ssl = Keyword.get(config, :ssl, false)

  %{host: host, port: port, ssl: ssl}
end

✅ DO: Document your options clearly

@doc """
## Options

  * `:timeout` - Request timeout in milliseconds (default: 5000)
  * `:retries` - Number of retry attempts (default: 3)
  * `:ssl` - Enable SSL connection (default: false)
"""
def connect(url, opts \\ []) do
  # Implementation
end

❌ DON'T: Use pattern matching directly on keyword lists

# Bad - fragile and order-dependent
def bad_pattern([timeout: t, retries: r]), do: {t, r}

# Good - safe extraction
def good_extraction(opts) do
  timeout = Keyword.get(opts, :timeout, 5000)
  retries = Keyword.get(opts, :retries, 3)
  {timeout, retries}
end

❌ DON'T: Use keyword lists for large datasets

# Bad - not suitable for large datasets
large_config = Enum.map(1..200, &{:"setting#{&1}", &1})

# Good - use maps for large collections
large_config = 1..200 |> Enum.map(&{:"setting#{&1}", &1}) |> Enum.into(%{})

Conclusion

Keyword lists are a specialized but essential data structure in Elixir that serve as the foundation for clean, readable APIs and configuration systems. In this comprehensive article, we've explored:

  • How keyword lists work internally as lists of two-element tuples
  • Creating and manipulating keyword lists with the Keyword module
  • The idiomatic pattern of using keyword lists for function options
  • Working safely with duplicate keys and understanding their semantic value
  • Why pattern matching should be avoided and what to use instead
  • When to choose each data structure for optimal results
  • Best practices for maintainable and performant code

Key takeaways:

  • Perfect for function options: Keyword lists are the idiomatic choice for optional parameters
  • Keep them small: Most suitable for fewer than 50 key-value pairs
  • Order matters: Unlike maps, keyword lists preserve insertion order
  • Duplicates allowed: Same keys can appear multiple times with semantic meaning
  • Use Keyword module: Always prefer Keyword.get/3 over pattern matching
  • Consider conversion: Convert to maps for large datasets or complex data modeling
  • Document thoroughly: Clear option documentation improves API usability

Keyword lists demonstrate Elixir's philosophy of having specialized tools for specific jobs. While maps excel at general key-value storage and data modeling, keyword lists shine in their specific niche of function options, small configurations, and DSL parameters. Their unique characteristics—ordered keys, duplicate support, and clean syntax—make them irreplaceable for creating readable, maintainable Elixir code.

Understanding when and how to use keyword lists effectively will help you write more idiomatic Elixir code that integrates seamlessly with the broader ecosystem, from Phoenix web applications to configuration management systems.

Further Reading

Next Steps

With a solid understanding of keyword lists, you're ready to explore Structs in Elixir. Structs provide a way to define custom data types with fixed fields, compile-time guarantees, and pattern matching capabilities that complement the flexible nature of maps and keyword lists.

In the next article, we'll explore:

  • Defining and creating custom structs
  • Struct vs map - when to use each approach
  • Pattern matching with structs for type safety
  • Updating structs with immutable operations

Structs represent the next level of data modeling in Elixir, providing the structure and type safety that make large applications maintainable and robust!


This content originally appeared on DEV Community and was authored by João Paulo Abreu


Print Share Comment Cite Upload Translate Updates
APA

João Paulo Abreu | Sciencx (2025-08-09T12:35:16+00:00) Learning Elixir: Keyword Lists. Retrieved from https://www.scien.cx/2025/08/09/learning-elixir-keyword-lists/

MLA
" » Learning Elixir: Keyword Lists." João Paulo Abreu | Sciencx - Saturday August 9, 2025, https://www.scien.cx/2025/08/09/learning-elixir-keyword-lists/
HARVARD
João Paulo Abreu | Sciencx Saturday August 9, 2025 » Learning Elixir: Keyword Lists., viewed ,<https://www.scien.cx/2025/08/09/learning-elixir-keyword-lists/>
VANCOUVER
João Paulo Abreu | Sciencx - » Learning Elixir: Keyword Lists. [Internet]. [Accessed ]. Available from: https://www.scien.cx/2025/08/09/learning-elixir-keyword-lists/
CHICAGO
" » Learning Elixir: Keyword Lists." João Paulo Abreu | Sciencx - Accessed . https://www.scien.cx/2025/08/09/learning-elixir-keyword-lists/
IEEE
" » Learning Elixir: Keyword Lists." João Paulo Abreu | Sciencx [Online]. Available: https://www.scien.cx/2025/08/09/learning-elixir-keyword-lists/. [Accessed: ]
rf:citation
» Learning Elixir: Keyword Lists | João Paulo Abreu | Sciencx | https://www.scien.cx/2025/08/09/learning-elixir-keyword-lists/ |

Please log in to upload a file.




There are no updates yet.
Click the Upload button above to add an update.

You must be logged in to translate posts. Please log in or register.