1054 words, 6 min read

In this post I’ll walk through how to use the Phoenix.Controller.send_download/3 function in a Phoenix controller to serve files or binaries for download. I’ll cover the API, use-cases, typical pitfalls and tips for production readiness. You’ll appreciate that the built-in support can simplify a lot of common download logic.

What is send_download/3?

In a Phoenix controller you often have actions that deliver HTML templates, JSON responses or redirects. But at times you want to let the user download a file (e.g., a report, export, image, PDF etc). Phoenix provides a convenient helper:

send_download(conn, kind, opts \\ [])

where:

  • conn is the %Plug.Conn{} struct in the controller action.
  • kind is one of the two forms:
    • {:file, path} — the path on disk (server filesystem) of the file to send.
    • {:binary, contents} — an in-memory binary (or iodata) to send.
  • opts is a keyword list of options (filename, content_type, disposition, charset, offset, length, encode).

In effect, send_download/3 wraps the underlying Plug / Cowboy file or binary-send mechanisms (such as Plug.Conn.send_file/3 or Plug.Conn.send_resp/3) and sets appropriate headers so that the browser prompts a download (by default) rather than rendering inline.

Here are the key option meanings:

  • :filename — the filename the browser sees / proposes to save. If omitted and you used {:file, path}, the filename is inferred from the path.
  • :content_type — override the MIME type. Phoenix infers from the extension otherwise.
  • :disposition:attachment (default) causes a "Save as" prompt; :inline causes browser to attempt rendering within.
  • :charset — e.g., "utf-8"
  • :offset and :length — when using {:file, path}, you can specify to send a slice of the file. (Defaults: offset = 0, length = :all)
  • :encode — whether the filename should be URI-encoded. Default is true. If you set encode: false, you must ensure your filename has no unsafe or special characters.

Why use send_download/3 (vs alternatives)?

Some reasons to prefer it:

  • It handles both disk-file and binary content in a unified API.
  • It correctly sets “Content-Disposition” header, so browser knows it's a download.
  • It infers content type and provides useful options.
  • It abstracts away the manual boilerplate of conn |> put_resp_header(...) |> send_file(...) etc.
  • Ideal for dynamic content generation (in memory) or serving existing files.

Compared to alternatives:

  • If you just want to serve a static file from priv/static or assets, you might use Plug.Static or direct link instead of a controller.
  • If you need streaming, range requests, partial reads, you may need to drop to lower-level Plug.Conn.send_file/5 or implement accept-ranges. For example, a user on ElixirForum noted that send_download doesn’t handle HTTP range/sliding requests for video streaming.

Example usage

Downloading a file from disk

Suppose you have a generated PDF at priv/reports/report-1234.pdf and you want the user to download it:

defmodule MyAppWeb.ReportController do
use MyAppWeb, :controller
def export(conn, %{"id" => id}) do
path = Application.app_dir(:my_app, "priv/reports/report-#{id}.pdf")
# You might check file existence etc here...
conn
|> send_download({:file, path},
filename: "report-#{id}.pdf",
content_type: "application/pdf"
)
end
end

In this example:

  • We use {:file, path} form.
  • We provide an explicit filename so the browser sees a meaningful name.
  • We override content_type just in case inference fails or you want to force it.

Sending dynamically generated binary content

Suppose the content is generated on-the-fly (e.g., CSV export, logs, etc) and you don’t want to write a file to disk:

def download_csv(conn, _params) do
csv = MyApp.Export.generate_csv_data()
conn
|> send_download({:binary, csv},
filename: "export-#{Date.utc_today()}.csv",
content_type: "text/csv; charset=utf-8"
)
end

Because it's binary form, Phoenix will handle the rest; you only need to supply filename (otherwise browser may show weird name) and (optionally) content_type.

Using offset/length for partial file sends

If you had a large file on disk but wanted to send only a subset bytes (e.g., a slice or preview), you can use offset and length options:

def download_slice(conn, %{"id" => id}) do
path = some_path_for_id(id)
conn
|> send_download({:file, path},
filename: "slice-#{id}.dat",
offset: 1_000_000, # skip first 1 000 000 bytes
length: 500_000 # send next 500 000 bytes
)
end

The docs list :offset and :length options. ([tmbb.github.io][2])

Things to watch & best practices

  1. Security / Path traversal When using {:file, path} form, do not interpolate user-supplied parameters directly into the path without validation. The docs warn: “Be careful to not interpolate the path from external parameters, as it could allow traversal of the filesystem.” So always validate or map IDs to safe paths.

  2. Filename encoding quirks One issue: If your filename has spaces or special characters, the default encoding may replace spaces with plus signs (+). Workaround: supply encode: false and pre-encode your filename yourself (e.g., URI.encode/2) if you care about how the filename appears exactly in the browser.

  3. Browser behaviour & inline vs attachment If you use disposition: :inline, the browser may attempt to open the file in-browser (PDF viewer, image preview, etc). For attachments you usually prefer :attachment. Make the UX decision depending on your file type.

  4. Large files / streaming / ranges If you are dealing with very large files, expect streaming, range requests, partial downloads or resumable downloads, then send_download may not be sufficient. For full control you might need to use Plug.Conn.send_file/3, or chunked responses, or implement accept-ranges headers.

  5. LiveView integration If you’re triggering download from a Phoenix LiveView (LiveView) page/button, you cannot directly send download from within LiveView socket handler (since LiveView uses WebSocket). The common pattern is to redirect to a controller route that uses send_download.

  6. Testing / Content-Type inference The :content_type is inferred from filename extension by Phoenix. If your extension is unusual, or you want to force a content type (for example when sending .json as .txt or .csv), explicitly set content_type:.

  7. After send_download the connection is “sent” Since send_download sends the response, you cannot then call further render/3, redirect/2, or other response-modifying functions on that conn. Doing so will raise Plug.Conn.AlreadySentError. This is the same as with send_file or send_resp.

Summary

  • Use send_download(conn, kind, opts) in your controller when you need to prompt the user to download a file or binary.
  • Choose between {:file, path} or {:binary, contents} depending on whether you have a file on disk or in-memory data.
  • Provide useful options like :filename, :content_type, :disposition, possibly :offset/:length.
  • Validate file paths, watch for filename encoding issues, and consider alternatives when you need streaming or range support.
  • In LiveView contexts, redirect to a controller route for download rather than trying to send download from within LiveView.