Spring Boot File Upload and Download to a Database
Published Updated Spring Boot 14 min read
Storing uploads as BLOBs in MySQL: the max_allowed_packet ceiling nobody mentions, why the whole file sits in heap on both the upload and the download path, and the point at which this design stops being the right one.
Storing uploaded files in the database is the design that needs the fewest moving parts: no shared
volume, no object store credentials, and a file that is transactional with the row that references
it. It is also the design that scales worst, and the failure is not gradual. It is a
PacketTooBigException at a size you did not choose.
Both halves are worth knowing before committing to it. Written against Spring Boot 3.2, Hibernate 6.4, MySQL 8 and Java 17.
The entity
@Entity
@Table(name = "files")
public class StoredFile {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private String id;
private String fileName;
private String contentType;
private long size;
@Lob
@Basic(fetch = FetchType.LAZY)
@Column(columnDefinition = "LONGBLOB")
private byte[] data;
}
Three annotations on data and each one is doing work.
@Lob maps the field to a large-object column. Without columnDefinition, Hibernate picks BLOB on
MySQL, which caps at 65,535 bytes, a limit you will meet with the first photograph anyone
uploads, and the resulting DataException names a column, not a size. LONGBLOB raises the ceiling
to 4 GB, at which point the real limit becomes the packet size, below.
@Basic(fetch = FetchType.LAZY) keeps the bytes out of a SELECT that only wants the metadata.
Lazy loading a basic attribute needs bytecode enhancement to be honoured: without it Hibernate
ignores the hint and every listing query drags every file through memory. The Maven plugin:
<plugin>
<groupId>org.hibernate.orm.tooling</groupId>
<artifactId>hibernate-enhance-maven-plugin</artifactId>
<configuration>
<enableLazyInitialization>true</enableLazyInitialization>
</configuration>
</plugin>
A simpler alternative that always works: put the bytes in their own entity mapped one-to-one to the metadata, and load it only on download.
Configuration
spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=12MB
spring.datasource.url=jdbc:mysql://localhost:3306/files_db
spring.jpa.hibernate.ddl-auto=update
max-file-size is per part; max-request-size covers the whole multipart body and must be larger,
because a request carries the other form fields and the part boundaries. Setting only the first is
why an upload just under the limit still fails.
Both default to 1 MB, so an upload that works in a unit test and fails in the browser is usually this and nothing else.
The MySQL limit that actually bites
max_allowed_packet caps a single statement, and an INSERT carrying a BLOB is one statement:
SHOW VARIABLES LIKE 'max_allowed_packet';
MySQL 8 defaults to 64 MB, older builds to 4 MB or 16 MB. Exceed it and the driver throws
PacketTooBigException, not a validation error, a dropped connection. Set the application’s own
max-file-size below the server’s packet size, and treat that as the real limit of this design.
The service
@Service
public class FileStorageService {
private final StoredFileRepository repository;
FileStorageService(StoredFileRepository repository) {
this.repository = repository;
}
@Transactional
public StoredFile store(MultipartFile file) throws IOException {
String name = StringUtils.cleanPath(
Objects.requireNonNull(file.getOriginalFilename(), "filename"));
if (name.contains("..")) {
throw new IllegalArgumentException("Invalid path sequence in " + name);
}
StoredFile stored = new StoredFile();
stored.setFileName(name);
stored.setContentType(file.getContentType());
stored.setSize(file.getSize());
stored.setData(file.getBytes());
return repository.save(stored);
}
@Transactional(readOnly = true)
public StoredFile load(String id) {
return repository.findById(id)
.orElseThrow(() -> new FileNotFoundException("No file with id " + id));
}
}
The .. check is not decoration. The client controls originalFilename entirely, and it is echoed
back in the Content-Disposition header on download; a filename is untrusted input even when the
bytes never touch a filesystem.
file.getContentType() is also client-supplied. If anything downstream branches on it (rendering
inline, generating a thumbnail) sniff the actual bytes instead of believing the header.
The controller
@RestController
@RequestMapping("/api/files")
public class FileController {
private final FileStorageService service;
FileController(FileStorageService service) {
this.service = service;
}
@PostMapping
public UploadResponse upload(@RequestParam("file") MultipartFile file) throws IOException {
StoredFile stored = service.store(file);
String uri = ServletUriComponentsBuilder.fromCurrentContextPath()
.path("/api/files/").path(stored.getId()).toUriString();
return new UploadResponse(stored.getFileName(), uri, stored.getContentType(), stored.getSize());
}
@GetMapping("/{id}")
public ResponseEntity<Resource> download(@PathVariable String id) {
StoredFile stored = service.load(id);
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(
stored.getContentType() != null
? stored.getContentType()
: MediaType.APPLICATION_OCTET_STREAM_VALUE))
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment()
.filename(stored.getFileName(), StandardCharsets.UTF_8).build().toString())
.body(new ByteArrayResource(stored.getData()));
}
}
ContentDisposition.attachment().filename(name, UTF_8) produces the filename*=UTF-8''... form,
which is what makes a non-ASCII filename survive the round trip and what escapes a filename
containing a quote. Concatenating the name into the header string by hand is the usual source of both
bugs.
attachment forces a download. inline lets the browser render it, and renders an uploaded HTML or
SVG file in your origin, which is a stored-XSS vector. Use attachment unless you have a specific
reason not to, and serve user content from a separate domain if you do.
Multiple files
@PostMapping("/batch")
public List<UploadResponse> uploadMany(@RequestParam("files") MultipartFile[] files) {
return Arrays.stream(files).map(this::uploadOne).toList();
}
max-request-size applies to the sum, so it needs raising for batch endpoints. This is the one case
where the two limits differ by more than overhead. Ten 5 MB files in one request is a 50 MB body and
a 50 MB transaction, so a batch endpoint is also where the packet ceiling arrives first. Saving each
file in its own transaction rather than one enclosing transaction keeps individual statements small
and lets a partial batch succeed, which is usually what a client wants anyway.
Errors worth handling explicitly
@RestControllerAdvice
public class UploadExceptionHandler {
@ExceptionHandler(MaxUploadSizeExceededException.class)
@ResponseStatus(HttpStatus.PAYLOAD_TOO_LARGE)
public Map<String, String> onTooLarge(MaxUploadSizeExceededException ex) {
return Map.of("error", "File exceeds the maximum upload size");
}
}
Without it, an oversized upload returns a 500 and the client is told nothing useful. Note that the container may abort the request before Spring sees it, in which case the browser reports a connection reset instead: a reason to enforce the size in the client too.
Streaming instead of buffering
byte[] is the readable version and the one that holds the whole file in memory. Mapping the column
as java.sql.Blob lets the driver hand back a stream, so the response is written in chunks:
@Lob
private Blob data;
@Transactional(readOnly = true)
public void writeTo(String id, OutputStream out) throws SQLException, IOException {
StoredFile stored = repository.findById(id).orElseThrow();
try (InputStream in = stored.getData().getBinaryStream()) {
in.transferTo(out);
}
}
Two constraints come with it. The stream is only valid while the transaction is open, so the transfer
has to happen inside it: returning the Blob to a controller and reading it after the transaction
commits throws. And MySQL’s Connector/J buffers the whole result row by default, so the stream is
only genuinely incremental with useCursorFetch=true and a fetch size set; on other databases the
behaviour differs again.
That awkwardness is itself informative. The database is not a file server, and the work needed to make it behave like one is a reasonable signal to store the bytes elsewhere.
When to stop doing this
Every byte of every file passes through the JVM heap twice: file.getBytes() on upload,
stored.getData() on download. Ten concurrent 10 MB downloads is 100 MB of heap that garbage
collection cannot help with while the response is being written.
The design is right when files are small and few, when transactional consistency with the row matters, and when adding an object store is genuinely more operational burden than it is worth. It stops being right at roughly the point where you notice the heap.
The alternative is to write bytes to storage and keep only the metadata and a key in the database — the shape covered in the filesystem upload/download example. S3 goes one step further: presigned URLs move the transfer out of the application entirely, so neither upload nor download touches the JVM.
Frequently asked questions
Why is my upload rejected at 1 MB?
That is Spring Boot’s default. Raise both
spring.servlet.multipart.max-file-size and max-request-size, the second must exceed the first.
What is the difference between max-file-size and max-request-size?
The first limits one part, the second the whole multipart body. A single-file upload still needs headroom in the second for the boundaries and other form fields.
Why does a large file throw PacketTooBigException?
The INSERT exceeded MySQL’s
max_allowed_packet. Check it with SHOW VARIABLES LIKE 'max_allowed_packet' and keep the
application’s limit below it.
Why is my BLOB truncated at 64 KB?
Hibernate mapped @Lob to MySQL’s BLOB, which caps at
65,535 bytes. Declare columnDefinition = "LONGBLOB".
Does @Basic(fetch = LAZY) actually work?
Only with bytecode enhancement enabled. Without it Hibernate loads the bytes on every query. Splitting the bytes into their own entity is the reliable alternative.
Is storing files in the database a bad idea?
It is a trade. You get transactional consistency and no extra infrastructure; you pay with heap on every transfer, larger backups and slower dumps. Small and few is fine; large or many is not.
Should I trust the content type from the client?
No. getContentType() is whatever the browser
sent. Sniff the bytes if anything depends on it, and never use it to decide whether to render inline.
How do I make a non-ASCII filename download correctly?
Build the header with
ContentDisposition.attachment().filename(name, StandardCharsets.UTF_8), which emits the RFC 6266
filename* form. Manual string concatenation breaks on both accents and quotes.
Why sanitise the filename if it never touches disk?
It is echoed into a response header and often
into a UI. .. sequences, quotes and control characters are injection vectors regardless of where
the bytes are stored.
What returns a 413 to the client?
A @RestControllerAdvice handling
MaxUploadSizeExceededException. Without one the client gets a 500, or a connection reset if the
container aborts the request first.