Spring Boot File Upload and Download
File upload and download in Spring Boot lets a REST API accept files from clients, such as invoices, product images or reports, and send them back later. Uploads arrive as multipart/form-data requests, and Spring hands each file to your controller as a MultipartFile. Downloads go the other way: you return the file as a Resource with the right headers so the browser knows what it is and what to call it. Getting the details right matters, because file endpoints are a common source of out-of-memory errors and security holes.
This tutorial explains how a multipart request is processed, then covers MultipartFile, @RequestParam vs @RequestPart, uploading several files, sending JSON alongside a file, and downloading with Content-Disposition. It builds a complete upload and download API on Spring Boot 4.1, configures and handles upload size limits, shows the real errors behind the most common pitfalls, and tests the code with MockMultipartFile.
1. What Is File Upload and Download in Spring Boot?
An upload is an HTTP request that carries a file from the client to the server. HTML forms and API clients send files as multipart/form-data: a request body split into parts, each with its own name and content type, so one request can carry several files and ordinary fields together.
A download is a response whose body is the file's bytes. The Content-Type header says what kind of file it is, and the Content-Disposition header says whether the browser should display it (inline) or save it (attachment), and under which file name.
Spring Boot handles the multipart parsing for you. With the web starter on the classpath, multipart support is enabled automatically, and you only write the controller and decide where files are kept.
2. How a Multipart Upload Works
Here is the body of a request that uploads an invoice together with a small JSON description. Each part starts with a boundary line that the client chooses, and the part's name is how Spring matches it to a controller parameter:
Before your controller runs, the server reads the request and enforces the size limits. With the default settings, each file part is written to a temporary file. Spring then wraps every file part in a MultipartFile, calls your controller, and deletes the temporary files when the request completes:
Two consequences follow. Size limits are checked before your code runs, so they need their own error handling (section 7). And a MultipartFile is only valid during the request, so you must save it before returning (section 8.4).
3. Setup
The web starter is all you need. The test starter is used in section 9:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
spring-boot-starter-webmvc. In Spring Boot 3.x it was spring-boot-starter-web. The spring.servlet.multipart.* property keys are the same in both versions.The application settings raise the upload limits and define where files go and which types are accepted:
spring.application.name=shop
# Upload limits: per file, and for the whole multipart request
spring.servlet.multipart.max-file-size=5MB
spring.servlet.multipart.max-request-size=20MB
# Where files are saved, and which types are accepted
app.storage.location=uploads
app.storage.allowed-content-types=image/png,image/jpeg,application/pdf
4. Uploading Files with MultipartFile
4.1 The MultipartFile Interface
MultipartFile (from org.springframework.web.multipart) represents one uploaded file part. The methods you will use most:
| Method | Returns | Notes |
|---|---|---|
getOriginalFilename() | The client's file name | Untrusted, can contain paths such as ../, and may be null |
getContentType() | The part's declared type | Set by the client, so treat it as a hint |
getSize() / isEmpty() | Size in bytes / whether it is 0 bytes | An empty file still arrives as a part |
getInputStream() | A stream of the content | Use it to copy large files without loading them into memory |
transferTo(Path) | Nothing | Saves the content to a file in one call |
getBytes() | The whole content as a byte[] | Loads the entire file into memory; fine only for small files |
4.2 @RequestParam vs @RequestPart
Both annotations bind a multipart part to a controller parameter. The difference shows up with parts that are not files:
@RequestParam | @RequestPart | |
|---|---|---|
File parts (MultipartFile) | Yes | Yes |
| Plain text fields | Yes, converted like query parameters | Yes |
| JSON part to an object, e.g. a record | No | Yes, using the part's Content-Type and a message converter |
| Also reads query parameters | Yes | No |
So use @RequestPart when a file comes with structured JSON, as the example's upload endpoint does. A missing required part is rejected with 400 Bad Request.
4.3 Sending JSON Together with a File
The client sends the JSON as its own part with Content-Type: application/json, and Spring converts it into the UploadDetails record. Marking it required = false makes the description optional:
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<StoredFile> upload(@RequestPart("file") MultipartFile file,
@RequestPart(name = "details", required = false) UploadDetails details) {
StoredFile stored = storageService.store(file, details == null ? null : details.description());
return ResponseEntity.created(URI.create("/api/files/" + stored.id())).body(stored);
}
4.4 Uploading Multiple Files
When a client sends several parts with the same name, bind them to a List<MultipartFile> (or MultipartFile[]):
@PostMapping(path = "/batch", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public List<StoredFile> uploadMany(@RequestParam("files") List<MultipartFile> files) {
return files.stream()
.map(file -> storageService.store(file, null))
.toList();
}
5. Downloading Files with Resource and Content-Disposition
Return a ResponseEntity<Resource>. A Resource is Spring's handle on a file, classpath entry or other source of bytes. Spring MVC streams it to the client in chunks, so even a large file never has to fit in memory. Set three headers:
Content-Typetells the client what the file is, for exampleapplication/pdf.Content-Dispositionchooses betweenattachment(save the file) andinline(show it in the browser if possible), and supplies the file name.Content-Lengthlets the client show download progress.
Build the disposition header with Spring's ContentDisposition builder rather than by joining strings. When you pass a charset, it adds an encoded filename* parameter, so names such as résumé Priya.pdf survive the trip:
ContentDisposition disposition = ContentDisposition.attachment()
.filename(file.originalName(), StandardCharsets.UTF_8)
.build();
// For "résumé Priya.pdf" the header becomes:
// Content-Disposition: attachment; filename="resume Priya.pdf"; filename*=UTF-8''r%C3%A9sum%C3%A9%20Priya.pdf
For Resource bodies, Spring MVC also supports HTTP range requests: it adds Accept-Ranges: bytes and answers a Range header with 206 Partial Content. That lets browsers resume interrupted downloads and seek in videos at no extra cost (section 6.4 shows it).
6. A Complete Example: File Upload and Download REST API
The example stores invoices and product images for a shop. It saves each upload under a generated id, keeps the file's metadata, and serves it back as a download. It builds on the controller style from the REST API tutorial and runs on Spring Boot 4.1 with Java 21.
| Endpoint | Purpose | Success |
|---|---|---|
POST /api/files | Upload one file with optional JSON details | 201 Created with Location |
POST /api/files/batch | Upload several files | 200 OK |
GET /api/files | List uploaded files | 200 OK |
GET /api/files/{id} | Download a file (?inline=true to display it) | 200 OK, or 206 for a range |
6.1 Storage Settings and DTOs
StorageProperties binds the app.storage.* settings to a record. The configuration properties tutorial explains how that binding works:
package com.javabytechie.shop.files;
import java.nio.file.Path;
import java.util.Set;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties("app.storage")
public record StorageProperties(Path location, Set<String> allowedContentTypes) {
}
package com.javabytechie.shop.files;
import java.time.Instant;
public record StoredFile(String id, String originalName, String contentType,
long size, String description, Instant uploadedAt) {
}
package com.javabytechie.shop.files;
public record UploadDetails(String description) {
}
package com.javabytechie.shop.files;
import org.springframework.core.io.Resource;
public record FileDownload(StoredFile file, Resource resource) {
}
package com.javabytechie.shop;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
@SpringBootApplication
@ConfigurationPropertiesScan
public class ShopApplication {
public static void main(String[] args) {
SpringApplication.run(ShopApplication.class, args);
}
}
6.2 The Storage Service
The service rejects empty files and types that are not allowed, then streams the content to disk under a random UUID. The client's file name is kept only as metadata:
package com.javabytechie.shop.files;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
@Service
public class FileStorageService {
private static final Logger log = LoggerFactory.getLogger(FileStorageService.class);
private final Path root;
private final StorageProperties properties;
// Metadata is kept in memory to keep the example short; a real app would use a database table
private final Map<String, StoredFile> files = new ConcurrentHashMap<>();
public FileStorageService(StorageProperties properties) {
this.properties = properties;
this.root = properties.location().toAbsolutePath().normalize();
try {
Files.createDirectories(root);
} catch (IOException e) {
throw new UncheckedIOException("Could not create upload directory " + root, e);
}
}
public StoredFile store(MultipartFile file, String description) {
if (file.isEmpty()) {
throw new InvalidFileException("File is empty");
}
String contentType = file.getContentType();
if (contentType == null || !properties.allowedContentTypes().contains(contentType)) {
throw new InvalidFileException("File type not allowed: " + contentType);
}
// Keep only the last part of the client's file name, e.g. "../../invoice.pdf" -> "invoice.pdf"
String originalName = StringUtils.getFilename(
StringUtils.cleanPath(Objects.requireNonNullElse(file.getOriginalFilename(), "file")));
// Save under a generated id, never under the name the client sent
String id = UUID.randomUUID().toString();
Path target = root.resolve(id);
try (InputStream in = file.getInputStream()) {
Files.copy(in, target);
} catch (IOException e) {
throw new UncheckedIOException("Could not save " + originalName, e);
}
StoredFile stored = new StoredFile(id, originalName, contentType, file.getSize(), description, Instant.now());
files.put(id, stored);
log.info("Stored {} ({} bytes) as {}", originalName, file.getSize(), id);
return stored;
}
public List<StoredFile> list() {
return files.values().stream()
.sorted(Comparator.comparing(StoredFile::uploadedAt))
.toList();
}
public FileDownload load(String id) {
StoredFile stored = files.get(id);
if (stored == null) {
throw new StoredFileNotFoundException(id);
}
Resource resource = new FileSystemResource(root.resolve(id));
if (!resource.exists()) {
throw new StoredFileNotFoundException(id);
}
return new FileDownload(stored, resource);
}
}
6.3 Controller and Error Handling
The exception handler extends ResponseEntityExceptionHandler, so Spring MVC's own errors, such as a missing part or an upload that is too large, are returned in the same ProblemDetail format as the application's errors. The exception handling tutorial covers this class in depth:
package com.javabytechie.shop.files;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.List;
import org.springframework.core.io.Resource;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@RestController
@RequestMapping("/api/files")
public class FileController {
private final FileStorageService storageService;
public FileController(FileStorageService storageService) {
this.storageService = storageService;
}
// One file, plus optional JSON details sent as a second part
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<StoredFile> upload(@RequestPart("file") MultipartFile file,
@RequestPart(name = "details", required = false) UploadDetails details) {
StoredFile stored = storageService.store(file, details == null ? null : details.description());
return ResponseEntity.created(URI.create("/api/files/" + stored.id())).body(stored);
}
// Several files sent under the same part name
@PostMapping(path = "/batch", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public List<StoredFile> uploadMany(@RequestParam("files") List<MultipartFile> files) {
return files.stream()
.map(file -> storageService.store(file, null))
.toList();
}
@GetMapping
public List<StoredFile> list() {
return storageService.list();
}
// ?inline=true lets the browser display the file instead of saving it
@GetMapping("/{id}")
public ResponseEntity<Resource> download(@PathVariable String id,
@RequestParam(defaultValue = "false") boolean inline) {
FileDownload download = storageService.load(id);
StoredFile file = download.file();
ContentDisposition disposition = (inline ? ContentDisposition.inline() : ContentDisposition.attachment())
.filename(file.originalName(), StandardCharsets.UTF_8)
.build();
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(file.contentType()))
.contentLength(file.size())
.header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString())
.body(download.resource());
}
}
package com.javabytechie.shop.files;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
// Extending ResponseEntityExceptionHandler turns Spring MVC's own errors
// (missing part, upload too large, ...) into ProblemDetail responses too
@RestControllerAdvice
public class FileExceptionHandler extends ResponseEntityExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(FileExceptionHandler.class);
@ExceptionHandler(InvalidFileException.class)
public ProblemDetail invalidFile(InvalidFileException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage());
}
@ExceptionHandler(StoredFileNotFoundException.class)
public ProblemDetail notFound(StoredFileNotFoundException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}
@Override
protected ResponseEntity<Object> handleMaxUploadSizeExceededException(MaxUploadSizeExceededException ex,
HttpHeaders headers, HttpStatusCode status, WebRequest request) {
// The cause says which limit was hit: file size, request size or number of parts
log.warn("Upload rejected: {}", ex.getMostSpecificCause().toString());
ProblemDetail body = ProblemDetail.forStatusAndDetail(status,
"Upload rejected: files can be up to 5MB each, 20MB per request, and 50 parts at most");
return handleExceptionInternal(ex, body, headers, status, request);
}
}
package com.javabytechie.shop.files;
public class InvalidFileException extends RuntimeException {
public InvalidFileException(String message) {
super(message);
}
}
package com.javabytechie.shop.files;
public class StoredFileNotFoundException extends RuntimeException {
public StoredFileNotFoundException(String id) {
super("File not found: " + id);
}
}
6.4 Run It
Start the app with ./mvnw spring-boot:run and upload with curl -F, which sends multipart/form-data. The ;type= suffix sets each part's content type:
$ curl -s -i -F 'file=@invoice-1001.pdf;type=application/pdf' \
-F 'details={"description":"Invoice for order 1001"};type=application/json' \
localhost:8080/api/files
HTTP/1.1 201
Location: /api/files/374e1d9b-068b-4492-adb3-9821f634fa8a
{"id":"374e1d9b-068b-4492-adb3-9821f634fa8a","originalName":"invoice-1001.pdf","contentType":"application/pdf","size":42,"description":"Invoice for order 1001","uploadedAt":"2026-10-25T05:17:30.272415Z"}
$ curl -s -F 'files=@blue-kettle.png;type=image/png' \
-F 'files=@order-1002.pdf;type=application/pdf' localhost:8080/api/files/batch
[{"id":"7cc25ff7-abe7-4a37-b0fc-c7177f24062c","originalName":"blue-kettle.png","contentType":"image/png","size":17,"description":null,"uploadedAt":"2026-10-25T05:17:30.301767Z"},{"id":"28706dff-bdf5-4940-bc62-b329cc36a089","originalName":"order-1002.pdf","contentType":"application/pdf","size":20,"description":null,"uploadedAt":"2026-10-25T05:17:30.302015Z"}]
Download the invoice, then ask for only its first 9 bytes:
$ curl -s -D - -o invoice.pdf localhost:8080/api/files/374e1d9b-068b-4492-adb3-9821f634fa8a
HTTP/1.1 200
Content-Disposition: attachment; filename="invoice-1001.pdf"; filename*=UTF-8''invoice-1001.pdf
Accept-Ranges: bytes
Content-Type: application/pdf
Content-Length: 42
$ curl -s -i -r 0-8 localhost:8080/api/files/374e1d9b-068b-4492-adb3-9821f634fa8a
HTTP/1.1 206
Content-Disposition: attachment; filename="invoice-1001.pdf"; filename*=UTF-8''invoice-1001.pdf
Content-Range: bytes 0-8/42
Content-Type: application/pdf
Content-Length: 9
%PDF-1.7
Unsupported, empty and unknown files get clear errors:
$ curl -s -i -F 'file=@notes.sh;type=text/x-sh' localhost:8080/api/files
HTTP/1.1 400
{"detail":"File type not allowed: text/x-sh","instance":"/api/files","status":400,"title":"Bad Request"}
$ curl -s -i -F 'file=@empty.pdf;type=application/pdf' localhost:8080/api/files
HTTP/1.1 400
{"detail":"File is empty","instance":"/api/files","status":400,"title":"Bad Request"}
$ curl -s -i -F 'other=@order-1002.pdf;type=application/pdf' localhost:8080/api/files
HTTP/1.1 400
{"detail":"Required part 'file' is not present.","instance":"/api/files","status":400,"title":"Bad Request"}
$ curl -s -i localhost:8080/api/files/does-not-exist
HTTP/1.1 404
{"detail":"File not found: does-not-exist","instance":"/api/files/does-not-exist","status":404,"title":"Not Found"}
The log shows each file saved under its generated id:
c.j.shop.files.FileStorageService : Stored invoice-1001.pdf (42 bytes) as 374e1d9b-068b-4492-adb3-9821f634fa8a
c.j.shop.files.FileStorageService : Stored blue-kettle.png (17 bytes) as 7cc25ff7-abe7-4a37-b0fc-c7177f24062c
c.j.shop.files.FileStorageService : Stored order-1002.pdf (20 bytes) as 28706dff-bdf5-4940-bc62-b329cc36a089
7. Configuring File Upload Size Limits
Spring Boot's default limit is only 1MB per file, which is the first thing most projects need to change. These are the settings that control uploads:
| Property | Default | What it controls |
|---|---|---|
spring.servlet.multipart.max-file-size | 1MB | Largest single file |
spring.servlet.multipart.max-request-size | 10MB | Largest whole multipart request, all parts together |
server.tomcat.max-part-count | 50 | Most parts per request, counting files and form fields |
spring.servlet.multipart.file-size-threshold | 0B | Size above which a part is written to a temporary file instead of kept in memory |
spring.servlet.multipart.location | Server's temp directory | Where those temporary files go |
spring.servlet.multipart.enabled | true | Turns multipart support on or off |
When a limit is exceeded, the server rejects the request before your controller runs, and Spring throws MaxUploadSizeExceededException. The example's handler turns all three cases into 413 Content Too Large:
# One 6MB file (limit: 5MB per file)
$ curl -s -i -F 'file=@big-catalogue.pdf;type=application/pdf' localhost:8080/api/files
HTTP/1.1 413
Connection: close
{"detail":"Upload rejected: files can be up to 5MB each, 20MB per request, and 50 parts at most","instance":"/api/files","status":413,"title":"Content Too Large"}
# Seven 3MB files, 21MB in total (limit: 20MB per request)
HTTP/1.1 413
{"detail":"Upload rejected: files can be up to 5MB each, 20MB per request, and 50 parts at most","instance":"/api/files/batch","status":413,"title":"Content Too Large"}
# Sixty 20-byte files (limit: 50 parts)
HTTP/1.1 413
{"detail":"Upload rejected: files can be up to 5MB each, 20MB per request, and 50 parts at most","instance":"/api/files/batch","status":413,"title":"Content Too Large"}
The log records which limit was hit, because the cause differs in each case:
WARN c.j.shop.files.FileExceptionHandler : Upload rejected: org.apache.tomcat.util.http.fileupload.impl.FileSizeLimitExceededException: The field file exceeds its maximum permitted size of 5242880 bytes.
WARN c.j.shop.files.FileExceptionHandler : Upload rejected: org.apache.tomcat.util.http.fileupload.impl.SizeLimitExceededException: the request was rejected because its size (22021212) exceeds the configured maximum (20971520)
WARN c.j.shop.files.FileExceptionHandler : Upload rejected: org.apache.tomcat.util.http.fileupload.impl.FileCountLimitExceededException: attachment
HttpStatus.CONTENT_TOO_LARGE. The old HttpStatus.PAYLOAD_TOO_LARGE is deprecated.8. Common Pitfalls and How to Fix Them
8.1 "Maximum upload size exceeded" on Small Files
A 2MB image fails even though nothing in your code limits it, because the default max-file-size is 1MB. How to fix it: set spring.servlet.multipart.max-file-size and max-request-size to sizes that suit your use case. Keep max-request-size at least as large as max-file-size.
8.2 413 for Many Small Files
Sending 60 small files to the batch endpoint fails with 413, even though they total only a few kilobytes. Tomcat's server.tomcat.max-part-count allows 50 parts per request, and the log shows FileCountLimitExceededException. How to fix it: raise the limit if you really need more parts, or upload in smaller batches.
8.3 Saving Files Under the Client's File Name
Writing to root.resolve(file.getOriginalFilename()) trusts a value the client controls. A name like ../../app/config.properties can write outside the upload folder (a path traversal attack), and two users uploading invoice.pdf overwrite each other. How to fix it: save under a generated id, as FileStorageService does, and keep the original name only as metadata. When you display it, strip any directories first, as StringUtils.getFilename(StringUtils.cleanPath(name)) does.
8.4 Using a MultipartFile After the Request Ends
Spring deletes the temporary files when the request completes. If you pass a MultipartFile to an @Async method or another thread, the content may already be gone when that code runs. How to fix it: save the file to permanent storage inside the request, then hand the stored id or path to the background job.
8.5 Loading Whole Files into Memory
Calling file.getBytes() on upload, or returning a byte[] on download, holds the entire file in memory for each request at the same time. A few concurrent large files can exhaust the heap. How to fix it: copy with getInputStream() or transferTo(), and return a Resource for downloads.
8.6 Trusting the Content-Type
The client sets each part's content type, so image/png does not prove the file is an image. How to fix it: use an allow-list as a first filter, as the example does, and if it matters, check the file's actual content with a detection library. Never serve user uploads from a folder where the server executes or interprets files.
8.7 415 Unsupported Media Type
Posting JSON to an endpoint declared with consumes = MediaType.MULTIPART_FORM_DATA_VALUE returns 415 with "Content-Type 'application/json' is not supported.". How to fix it: send the request as multipart/form-data, and don't set the Content-Type header by hand in the client, because it must include the boundary. Let curl -F, the browser's FormData or your HTTP client generate it.
9. Testing File Upload and Download
MockMultipartFile (from org.springframework.mock.web) builds an upload in memory. The unit test below runs the real service against JUnit's @TempDir. It checks that the folder is empty before the upload and holds the file after it, and that rejected files leave nothing behind:
package com.javabytechie.shop.files;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.mock.web.MockMultipartFile;
class FileStorageServiceTest {
@TempDir
Path uploadDir;
private FileStorageService service;
@BeforeEach
void setUp() {
service = new FileStorageService(new StorageProperties(uploadDir, Set.of("application/pdf")));
}
@Test
void storesFileUnderGeneratedIdAndKeepsOnlyTheFileName() throws IOException {
MockMultipartFile pdf = new MockMultipartFile("file", "../../invoice-1001.pdf",
"application/pdf", "%PDF-1.7 invoice".getBytes());
assertThat(service.list()).isEmpty();
try (var stream = Files.list(uploadDir)) {
assertThat(stream).isEmpty();
}
StoredFile stored = service.store(pdf, "Invoice for order 1001");
assertThat(stored.originalName()).isEqualTo("invoice-1001.pdf");
assertThat(service.list()).containsExactly(stored);
assertThat(Files.readString(uploadDir.resolve(stored.id()))).isEqualTo("%PDF-1.7 invoice");
assertThat(service.load(stored.id()).resource().contentLength()).isEqualTo(16);
}
@Test
void rejectsEmptyAndDisallowedFilesWithoutSavingThem() throws IOException {
MockMultipartFile empty = new MockMultipartFile("file", "empty.pdf", "application/pdf", new byte[0]);
MockMultipartFile script = new MockMultipartFile("file", "run.sh", "text/x-sh", "rm -rf /".getBytes());
assertThatThrownBy(() -> service.store(empty, null))
.isInstanceOf(InvalidFileException.class).hasMessage("File is empty");
assertThatThrownBy(() -> service.store(script, null))
.isInstanceOf(InvalidFileException.class).hasMessage("File type not allowed: text/x-sh");
assertThat(service.list()).isEmpty();
try (var stream = Files.list(uploadDir)) {
assertThat(stream).isEmpty();
}
}
@Test
void unknownIdIsNotFound() {
assertThatThrownBy(() -> service.load("missing-id"))
.isInstanceOf(StoredFileNotFoundException.class).hasMessage("File not found: missing-id");
}
}
The web layer test sends multipart requests through MockMvc with multipart() and replaces the service with a Mockito mock using @MockitoBean. Size limits are enforced by the real server, not by MockMvc, so check them by running the app as in section 7. The Spring Boot testing tutorial covers @WebMvcTest in more detail:
package com.javabytechie.shop.files;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.time.Instant;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
@WebMvcTest(FileController.class)
class FileControllerTest {
@Autowired
MockMvc mockMvc;
@MockitoBean
FileStorageService storageService;
private final StoredFile invoice = new StoredFile("a1b2", "invoice-1001.pdf", "application/pdf",
16, "Invoice for order 1001", Instant.parse("2026-10-25T09:30:00Z"));
@Test
void uploadReturns201WithLocation() throws Exception {
MockMultipartFile file = new MockMultipartFile("file", "invoice-1001.pdf",
"application/pdf", "%PDF-1.7 invoice".getBytes());
MockMultipartFile details = new MockMultipartFile("details", "", "application/json",
"{\"description\":\"Invoice for order 1001\"}".getBytes());
given(storageService.store(any(), eq("Invoice for order 1001"))).willReturn(invoice);
mockMvc.perform(multipart("/api/files").file(file).file(details))
.andExpect(status().isCreated())
.andExpect(header().string("Location", "/api/files/a1b2"))
.andExpect(jsonPath("$.originalName").value("invoice-1001.pdf"));
verify(storageService).store(file, "Invoice for order 1001");
}
@Test
void downloadSendsFileAsAttachment() throws Exception {
given(storageService.load("a1b2"))
.willReturn(new FileDownload(invoice, new ByteArrayResource("%PDF-1.7 invoice".getBytes())));
mockMvc.perform(get("/api/files/a1b2"))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_PDF))
.andExpect(header().string("Content-Disposition", "attachment; filename=\"invoice-1001.pdf\"; filename*=UTF-8''invoice-1001.pdf"))
.andExpect(content().string("%PDF-1.7 invoice"));
}
@Test
void rejectedFileReturns400() throws Exception {
MockMultipartFile script = new MockMultipartFile("file", "run.sh", "text/x-sh", "echo".getBytes());
given(storageService.store(any(), any())).willThrow(new InvalidFileException("File type not allowed: text/x-sh"));
mockMvc.perform(multipart("/api/files").file(script))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.detail").value("File type not allowed: text/x-sh"));
}
@Test
void unknownFileReturns404() throws Exception {
given(storageService.load("missing-id")).willThrow(new StoredFileNotFoundException("missing-id"));
mockMvc.perform(get("/api/files/missing-id"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.detail").value("File not found: missing-id"));
}
}
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0 -- in com.javabytechie.shop.files.FileControllerTest
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 -- in com.javabytechie.shop.files.FileStorageServiceTest
[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0
@MockBean was removed in Spring Boot 4. Use @MockitoBean. In Boot 4, @WebMvcTest lives in org.springframework.boot.webmvc.test.autoconfigure and comes from spring-boot-starter-webmvc-test.10. Where to Store Uploaded Files
The example writes to a local folder, which is the simplest option but not always the right one:
| Storage | Good fit | Poor fit |
|---|---|---|
| Local disk or mounted volume | One server, internal tools, small volumes | Several instances behind a load balancer, or containers without a persistent volume |
| Object storage (e.g. Amazon S3, Azure Blob, Google Cloud Storage) | Cloud deployments, large or many files, sharing between instances | Fully offline environments |
| Database column (BLOB) | Small files that must stay consistent with a row in the same transaction | Large files, which bloat the database and backups |
Whatever you choose, keep the metadata (id, original name, type, size, owner) in a database table. The example keeps it in a ConcurrentHashMap only to stay short, so it is lost on restart. Hiding storage behind a service like FileStorageService lets you switch from disk to object storage without touching the controller.
11. Best Practices
- Set explicit size limits for files and requests that match your use case, and return a clear 413 when they are exceeded.
- Store files under generated ids, never under the client's file name.
- Allow-list content types and reject empty files before saving anything.
- Stream instead of buffering: copy uploads with
getInputStream()and return downloads as aResource. - Build Content-Disposition with ContentDisposition and a charset, so non-ASCII file names work.
- Choose attachment or inline deliberately: force
attachmentfor file types a browser could execute or render as a page. - Save uploads within the request before handing work to another thread.
- Return the same error format everywhere by extending
ResponseEntityExceptionHandler. - Keep file storage behind a service so you can move to object storage later.
- Test with MockMultipartFile and a temporary folder, checking that rejected files leave nothing on disk.
12. Summary
File upload and download in Spring Boot comes down to a few steps. Accept multipart/form-data requests with MultipartFile parameters, using @RequestPart when a JSON part comes with the file and a List for several files. Validate the file, stream it to storage under a generated id, and keep its metadata. Serve it back as a ResponseEntity<Resource> with Content-Type, Content-Length and a Content-Disposition header built with ContentDisposition. Raise max-file-size and max-request-size from their small defaults, and turn MaxUploadSizeExceededException into a clear 413 response. The main recommendation: never trust the client's file name or content type, and never hold whole files in memory.
Frequently Asked Questions (FAQ)
Q1. How do I increase the maximum file upload size in Spring Boot?
Set spring.servlet.multipart.max-file-size for a single file and spring.servlet.multipart.max-request-size for the whole request, for example 50MB and 100MB. The defaults are 1MB and 10MB. Use -1 for no limit only if another layer, such as a proxy, enforces one.
Q2. How do I handle MaxUploadSizeExceededException?
The exception is thrown before your controller runs, so handle it in a @RestControllerAdvice. If the advice extends ResponseEntityExceptionHandler, override handleMaxUploadSizeExceededException; otherwise add an @ExceptionHandler(MaxUploadSizeExceededException.class) method. Return status 413 (HttpStatus.CONTENT_TOO_LARGE) with a message that states the limits.
Q3. What is the difference between @RequestParam and @RequestPart for file uploads?
Both bind a MultipartFile. @RequestPart can also convert a non-file part, such as JSON, into an object using its Content-Type, while @RequestParam treats non-file parts as plain strings and also reads query parameters. Use @RequestPart when a file comes with structured data.
Q4. How do I upload a file and JSON data in the same request?
Send a multipart/form-data request with two parts: the file, and a part with Content-Type: application/json holding the JSON. In the controller, use @RequestPart("file") MultipartFile file and @RequestPart("details") UploadDetails details. Without that content type, Spring cannot tell the part is JSON and the request fails with 415, so set it explicitly in the client.
Q5. Why does my file upload return 415 Unsupported Media Type?
The request was not sent as multipart/form-data, or its Content-Type header has no boundary because it was set by hand. Let the client library or browser build the multipart request and its header. Also check that the endpoint's consumes value matches what the client sends.
Q6. How do I download a file with a non-English file name?
Build the header with ContentDisposition.attachment().filename(name, StandardCharsets.UTF_8). Spring then adds an encoded filename* parameter that modern browsers use, plus an ASCII fallback in filename. Joining the header by hand usually breaks on spaces, quotes or accented letters.
Q7. Should I store uploaded files in the database or on the file system?
Store the file content in object storage or on disk, and its metadata in the database. Large binary columns make the database and its backups grow quickly. Keeping small files in the database can make sense when they must change in the same transaction as their row.
Q8. How do I stream large files for download?
Return ResponseEntity<Resource>, for example a FileSystemResource. Spring MVC copies it to the response in chunks and supports range requests, so the file is never fully loaded into memory. Avoid returning byte[] for large files.