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
- Elixir Official Documentation - Keyword
- Elixir School - Keyword Lists
- Programming Elixir by Dave Thomas - Collections Chapter
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!
Top comments (0)