Spring Boot Tutorial
File Upload and Download
Profile pictures, assignment submissions, invoices, CSV imports and report exports — nearly every application moves files over HTTP. Doing it well means more than accepting a MultipartFile: you need size limits, content validation, safe file names, storage outside the web root (or in object storage), and efficient streaming for large downloads.
This lesson builds a complete file service in Spring Boot: multipart uploads with metadata, validation, storing files on disk or in S3-compatible storage, downloads with correct headers, inline previews versus attachments, and streaming large generated files.
Multipart Uploads
Browsers and clients upload files with multipart/form-data. Spring Boot configures multipart support automatically; your controller receives MultipartFile parameters via @RequestParam or @RequestPart. Use @RequestPart when a request combines a file with a JSON part (for example metadata). Configure limits with spring.servlet.multipart.max-file-size and max-request-size; the defaults are 1MB and 10MB.
Validating Uploads
Never trust the client: the file name can contain ../ path traversal, the declared Content-Type can lie, and a ".jpg" can be an executable. Generate your own storage name (a UUID), check the extension against an allow-list, verify the actual content type (for example with Apache Tika), and enforce size limits. Store uploads outside any directory served as static content.
Where to Store Files
Local disk is fine for a single server. As soon as you run more than one instance or deploy to containers, use object storage — Amazon S3, Google Cloud Storage, Azure Blob, or an S3-compatible service like MinIO or Cloudflare R2 — and keep only metadata (owner, original name, size, storage key) in your database. For private files, serve downloads through your application (with authorization checks) or generate short-lived pre-signed URLs.
Downloads
Return a ResponseEntity<Resource> with Content-Type, Content-Length and a Content-Disposition header: attachment makes the browser download the file, inline displays it (PDFs, images). ContentDisposition.attachment().filename(name, UTF_8) encodes non-ASCII names correctly. Spring streams the resource without loading it fully into memory.
Streaming Generated Content
For large exports generated on the fly (CSV of a million rows), return StreamingResponseBody and write to the output stream as you read from the database, so memory use stays constant.
Examples
Upload limits and a storage service
# application.yml
spring:
servlet:
multipart:
max-file-size: 5MB
max-request-size: 6MB
app:
storage:
root: D:/webnest-uploads # outside the project and static folders
@Service
public class FileStorageService {
private static final Set<String> ALLOWED = Set.of("png", "jpg", "jpeg", "pdf");
private final Path root;
public FileStorageService(@Value("${app.storage.root}") Path root) throws IOException {
this.root = Files.createDirectories(root);
}
public StoredFile store(MultipartFile file) throws IOException {
if (file.isEmpty()) throw new IllegalArgumentException("Empty file");
String original = StringUtils.cleanPath(Objects.requireNonNull(file.getOriginalFilename()));
String ext = StringUtils.getFilenameExtension(original);
if (ext == null || !ALLOWED.contains(ext.toLowerCase())) {
throw new IllegalArgumentException("File type not allowed: " + original);
}
String key = UUID.randomUUID() + "." + ext.toLowerCase(); // never use the client's name on disk
try (InputStream in = file.getInputStream()) {
Files.copy(in, root.resolve(key));
}
return new StoredFile(key, original, file.getSize(), file.getContentType());
}
public Resource load(String key) {
Path path = root.resolve(key).normalize();
if (!path.startsWith(root)) throw new SecurityException("Invalid path");
return new FileSystemResource(path);
}
}
public record StoredFile(String key, String originalName, long size, String contentType) {}
(Files are saved as e.g. D:/webnest-uploads/7f3c9a1e-....pdf while the original name is kept only as metadata.)
Upload endpoints: single file and file plus JSON metadata
public record AssignmentMeta(@NotBlank String title, Long courseId) {}
@RestController
@RequestMapping("/api/files")
public class FileController {
private final FileStorageService storage;
public FileController(FileStorageService storage) {
this.storage = storage;
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public StoredFile upload(@RequestParam("file") MultipartFile file) throws IOException {
return storage.store(file);
}
@PostMapping(value = "/assignments", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public Map<String, Object> submitAssignment(@RequestPart("meta") @Valid AssignmentMeta meta,
@RequestPart("files") List<MultipartFile> files) throws IOException {
List<StoredFile> stored = new ArrayList<>();
for (MultipartFile f : files) stored.add(storage.store(f));
return Map.of("title", meta.title(), "files", stored);
}
}
curl -F "file=@notes.pdf" http://localhost:8080/api/files
{"key":"7f3c9a1e-5b2d-4c1a-9e8f-2a6b3c4d5e6f.pdf","originalName":"notes.pdf","size":184233,"contentType":"application/pdf"}
curl -F 'meta={"title":"JPA homework","courseId":3};type=application/json' \
-F "files=@a.pdf" -F "files=@b.png" http://localhost:8080/api/files/assignments
{"title":"JPA homework","files":[{...},{...}]}
curl -F "file=@virus.exe" ... -> 400 File type not allowed: virus.exe
curl -F "file=@big-video.mp4" ... -> 413 Payload Too Large (MaxUploadSizeExceededException)
Downloading as attachment or inline preview
@GetMapping("/{key}")
public ResponseEntity<Resource> download(@PathVariable String key,
@RequestParam(defaultValue = "false") boolean inline) throws IOException {
StoredFile meta = fileMetadataRepository.findByKey(key).orElseThrow(); // also check ownership here!
Resource resource = storage.load(key);
ContentDisposition disposition = (inline ? ContentDisposition.inline() : ContentDisposition.attachment())
.filename(meta.originalName(), StandardCharsets.UTF_8)
.build();
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(meta.contentType()))
.contentLength(resource.contentLength())
.header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString())
.body(resource);
}
GET /api/files/7f3c...pdf
Content-Type: application/pdf
Content-Length: 184233
Content-Disposition: attachment; filename="notes.pdf"
GET /api/files/7f3c...pdf?inline=true
Content-Disposition: inline; filename="notes.pdf" (browser opens the PDF viewer)
Streaming a large CSV export
@GetMapping("/api/reports/orders.csv")
public ResponseEntity<StreamingResponseBody> exportOrders() {
StreamingResponseBody body = out -> {
try (Writer w = new OutputStreamWriter(out, StandardCharsets.UTF_8);
Stream<OrderRow> rows = orderRepository.streamAllForExport()) { // @QueryHints fetch size
w.write("id,customer,total,created_at\n");
rows.forEach(r -> {
try {
w.write(r.id() + "," + r.customer() + "," + r.total() + "," + r.createdAt() + "\n");
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});
}
};
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"orders.csv\"")
.contentType(new MediaType("text", "csv"))
.body(body);
}
curl -O http://localhost:8080/api/reports/orders.csv
(1,000,000 rows streamed; heap usage stays flat instead of building a 200 MB string)
Common Mistakes
- Saving files using the client-supplied file name, enabling path traversal (../../) and overwriting other users' files.
- Trusting the Content-Type header or extension alone to decide whether a file is safe.
- Storing uploads inside src/main/resources/static or another publicly served folder.
- Keeping files on local disk in a multi-instance or container deployment where the next request hits a different instance.
- Serving private files without checking that the current user may access them.
Key Points to Remember
- Receive files as MultipartFile via @RequestParam or @RequestPart; configure spring.servlet.multipart limits.
- Generate your own storage keys, allow-list extensions and verify content.
- Use object storage (S3, GCS, Azure Blob, MinIO) for scalable deployments and keep metadata in the database.
- Return ResponseEntity<Resource> with ContentDisposition attachment or inline for downloads.
- Use StreamingResponseBody for large generated exports.
Practice the examples
Change an input, predict the result, then compare it with the output. Explain why the result changes.
Use your local JDK or project IDE for these examples. Codelab currently runs Python and HTML/CSS/JavaScript; framework examples may need project dependencies.