# Schemaless changesets validate forms without the database

Need to validate form data, preview changes, or validate multi-step workflows without hitting the database? Use schemaless changesets with `Ecto.Changeset.apply_action/2` to get validation errors and transformed data.

```elixir
# ✅ Schemaless changeset for contact form validation
defmodule MyApp.ContactForm do
  import Ecto.Changeset

  @types %{
    name: :string,
    email: :string,
    message: :string,
    subscribe: :boolean
  }

  def changeset(params) do
    {%{}, @types}                                        # Empty map + type definition
    |> cast(params, Map.keys(@types))                    # Cast params to types
    |> validate_required([:name, :email, :message])      # Required fields
    |> validate_format(:email, ~r/@/)                    # Email format
    |> validate_length(:message, min: 10, max: 1000)     # Message length
  end

  def validate(params) do
    params
    |> changeset()
    |> apply_action(:insert)  # Returns {:ok, data} or {:error, changeset}
  end
end
```

**Why this works:**

- **`{%{}, @types}`** - Creates changeset without Ecto schema (just a map + type spec)
- **`apply_action(:insert)`** - Validates and returns clean data, or errors if invalid
- **No database** - Perfect for forms that don't map to a single table

**Using in LiveView forms:**

```elixir
defmodule MyAppWeb.ContactLive do
  use MyAppWeb, :live_view

  def mount(_params, _session, socket) do
    {:ok, assign(socket, form: to_form(ContactForm.changeset(%{})))}
  end

  def handle_event("validate", %{"contact" => params}, socket) do
    changeset =
      params
      |> ContactForm.changeset()
      |> Map.put(:action, :validate)  # Show errors immediately

    {:noreply, assign(socket, form: to_form(changeset))}
  end

  def handle_event("submit", %{"contact" => params}, socket) do
    case ContactForm.validate(params) do
      {:ok, valid_data} ->
        # Send email, save to external API, etc.
        send_contact_email(valid_data)
        {:noreply, put_flash(socket, :info, "Message sent!")}

      {:error, changeset} ->
        {:noreply, assign(socket, form: to_form(changeset))}
    end
  end
end
```

**Multi-step form validation:**

```elixir
defmodule MyApp.RegistrationWizard do
  import Ecto.Changeset

  @step1_types %{name: :string, email: :string}
  @step2_types %{company: :string, role: :string}
  @step3_types %{password: :string, password_confirmation: :string}

  def validate_step(1, params) do
    {%{}, @step1_types}
    |> cast(params, [:name, :email])
    |> validate_required([:name, :email])
    |> validate_format(:email, ~r/@/)
    |> apply_action(:insert)  # Check if step 1 is valid
  end

  def validate_step(2, params) do
    {%{}, @step2_types}
    |> cast(params, [:company, :role])
    |> validate_required([:company, :role])
    |> apply_action(:insert)
  end

  def validate_step(3, params) do
    {%{}, @step3_types}
    |> cast(params, [:password, :password_confirmation])
    |> validate_required([:password, :password_confirmation])
    |> validate_length(:password, min: 12)
    |> validate_confirmation(:password)
    |> apply_action(:insert)
  end

  def complete_registration(all_params) do
    # All steps validated, now save to database
    with {:ok, step1} <- validate_step(1, all_params),
         {:ok, step2} <- validate_step(2, all_params),
         {:ok, step3} <- validate_step(3, all_params) do
      # Combine validated data and insert
      user_params = step1 |> Map.merge(step2) |> Map.merge(step3)
      %User{}
      |> User.changeset(user_params)
      |> Repo.insert()
    end
  end
end
```

**API parameter validation:**

```elixir
defmodule MyApp.API.SearchParams do
  import Ecto.Changeset

  @types %{
    query: :string,
    page: :integer,
    per_page: :integer,
    sort_by: :string,
    order: :string
  }

  def validate(params) do
    {%{}, @types}
    |> cast(params, Map.keys(@types))
    |> validate_required([:query])
    |> validate_number(:page, greater_than: 0)
    |> validate_number(:per_page, greater_than: 0, less_than_or_equal_to: 100)
    |> validate_inclusion(:sort_by, ["relevance", "date", "title"])
    |> validate_inclusion(:order, ["asc", "desc"])
    |> apply_defaults()
    |> apply_action(:insert)
  end

  defp apply_defaults(changeset) do
    changeset
    |> put_default(:page, 1)
    |> put_default(:per_page, 20)
    |> put_default(:sort_by, "relevance")
    |> put_default(:order, "desc")
  end

  defp put_default(changeset, field, value) do
    case get_field(changeset, field) do
      nil -> put_change(changeset, field, value)
      _ -> changeset
    end
  end
end
```

**The power of `apply_action`:**

```elixir
# Returns {:ok, %{name: "Alice", email: "alice@example.com"}} if valid
ContactForm.validate(%{"name" => "Alice", "email" => "alice@example.com"})

# Returns {:error, %Ecto.Changeset{}} with errors if invalid
ContactForm.validate(%{"name" => "", "email" => "invalid"})
```

**When to use schemaless changesets:**

- ✅ Contact forms, search forms, filters (no database backing)
- ✅ Multi-step wizards (validate each step independently)
- ✅ API parameter validation (type coercion + validation)
- ✅ Form previews (validate before commit)
- ✅ External API integrations (validate before sending)

**Pro tips:**

- Use `:insert`, `:update`, or `:delete` with `apply_action` - they're semantically equivalent for schemaless changesets
- Combine multiple changesets with `with` for complex validation flows
- Add custom validators with `validate_change/3` just like schema changesets
- Use `Ecto.Changeset.traverse_errors/2` to format errors for APIs

**Links:**

- [Ecto.Changeset.apply_action/2](https://hexdocs.pm/ecto/Ecto.Changeset.html#apply_action/2)
- [Schemaless Changesets Guide](https://hexdocs.pm/ecto/Ecto.Changeset.html#module-schemaless-changesets)


---

Created by: almirsarajcic
Date: June 26, 2026
URL: https://elixirdrops.net/d/kg3pPtVH
