β¨ Export Excel files the declarative way
β Before (POI): // 50 lines of boilerplate...
β After (Excel Annotator): @ExcelColumn(header = "Name") private String name;
If you love @RestController and @Service,
you'll love Excel Annotator.
νκ΅μ΄ | English
Generate Excel files with annotations only - no POI code required!
<dependency>
<groupId>io.github.takoeats</groupId>
<artifactId>excel-annotator</artifactId>
<version>2.3.4</version>
</dependency>import io.github.takoeats.excelannotator.annotation.ExcelSheet;
import io.github.takoeats.excelannotator.annotation.ExcelColumn;
@ExcelSheet("Customer List")
public class CustomerDTO {
@ExcelColumn(header = "Customer ID", order = 1)
private Long customerId;
@ExcelColumn(header = "Name", order = 2)
private String customerName;
@ExcelColumn(header = "Email", order = 3)
private String email;
}Or use autoColumn for even simpler setup:
import io.github.takoeats.excelannotator.annotation.ExcelSheet;
@ExcelSheet(value = "Customer List", autoColumn = true)
public class CustomerDTO {
private Long customerId; // Auto-exported: header = "customerId"
private String customerName; // Auto-exported: header = "customerName"
private String email; // Auto-exported: header = "email"
}import io.github.takoeats.excelannotator.ExcelExporter;
@PostMapping("/download/customers")
public void downloadExcel(HttpServletResponse response) {
List<CustomerDTO> customers = customerService.getCustomers();
// Fluent API (Recommended)
ExcelExporter.excel(response)
.fileName("customers.xlsx")
.write(customers); // Return value (final filename) can be ignored
}Done! π The browser downloads customers.xlsx.
Simple, intuitive builder pattern for all export scenarios:
// HttpServletResponse (Web Download)
ExcelExporter.excel(response)
.fileName("customers.xlsx")
.write(customerList); // Return value (final filename) can be ignored
ExcelExporter.excel(response)
.fileName("customers.xlsx")
.write(customerStream); // Return value (final filename) can be ignored
ExcelExporter.excel(response)
.fileName("report.xlsx")
.write(multiSheetMap); // Return value (final filename) can be ignored
ExcelExporter.excel(response)
.fileName("customers.xlsx")
.write(query, dataProvider, converter); // Return value (final filename) can be ignored
// OutputStream (File Save)
String fileName = ExcelExporter.excel(outputStream)
.fileName("customers.xlsx")
.write(customerList); // Returns processed filename// HttpServletResponse (Web Download)
// List
ExcelExporter.csv(response)
.fileName("customers.csv")
.write(customerList); // Return value (final filename) can be ignored
// Stream
ExcelExporter.csv(response)
.fileName("customers.csv")
.write(customerStream); // Return value (final filename) can be ignored
// OutputStream (File Save)
String fileName = ExcelExporter.csv(outputStream)
.fileName("customers.csv")
.write(customerList);Key Benefits:
- Type-safe: Compile-time guarantees for return types
- Unified interface: Same pattern for Response/OutputStream, Excel/CSV
- Flexible data: Supports List, Stream, Map (mixed List/Stream values)
- Cleaner code: No method name confusion (
excelFromListvsexcelFromStream)
β οΈ Deprecation Notice: The legacy static methods (excelFromList,excelFromStream, etc.) are deprecated and will be removed in version 3.0.0. Please migrate to the Fluent API above for better type safety and readability.
ExcelExporter provides 17 static methods (deprecated) for various use cases.
| Method Signature | Output | Filename | Description |
|---|---|---|---|
excelFromList(response, fileName, list) |
HttpServletResponse | Required | Web download (single sheet) |
excelFromList(response, fileName, map) |
HttpServletResponse | Required | Web download (multi-sheet) |
excelFromList(outputStream, fileName, list) |
OutputStream | Required | File save (single sheet) |
excelFromList(outputStream, list) |
OutputStream | Auto | File save (auto filename) |
excelFromList(outputStream, fileName, map) |
OutputStream | Required | File save (multi-sheet) |
| Method Signature | Output | Filename | Description |
|---|---|---|---|
excelFromList(response, fileName, query, provider, converter) |
HttpServletResponse | Required | Web download (query separated) |
excelFromList(outputStream, fileName, query, provider, converter) |
OutputStream | Required | File save (query separated) |
excelFromList(outputStream, query, provider, converter) |
OutputStream | Auto | File save (query separated, auto filename) |
| Method Signature | Output | Filename | Description |
|---|---|---|---|
excelFromStream(response, fileName, stream) |
HttpServletResponse | Required | Web download (single sheet streaming) |
excelFromStream(response, fileName, streamMap) |
HttpServletResponse | Required | Web download (multi-sheet streaming) |
excelFromStream(outputStream, fileName, stream) |
OutputStream | Required | File save (single sheet streaming) |
excelFromStream(outputStream, stream) |
OutputStream | Auto | File save (auto filename) |
excelFromStream(outputStream, fileName, streamMap) |
OutputStream | Required | File save (multi-sheet streaming) |
| Method Signature | Output | Filename | Description |
|---|---|---|---|
csvFromList(response, fileName, list) |
HttpServletResponse | Required | CSV web download (List) |
csvFromList(outputStream, fileName, list) |
OutputStream | Required | CSV file save (List) |
csvFromStream(response, fileName, stream) |
HttpServletResponse | Required | CSV web download (Stream) |
csvFromStream(outputStream, fileName, stream) |
OutputStream | Required | CSV file save (Stream) |
π CSV Format Features:
- β RFC 4180 standard fully compliant
- β All fields quoted (safe special character handling)
- β CRLF (\r\n) line breaks
- β UTF-8 BOM included (Excel compatibility)
- β Preserves newlines, commas, quotes within fields
π‘ Selection Guide:
- < 10K rows: List API (simple, fast)
- 10K~1M rows: Stream API recommended (memory efficient)
- > 1M rows: Stream API required (List has 1M limit)
- Query reuse needed: Data Provider pattern
- Simple data exchange: CSV API (no styling, high compatibility)
@RestController
public class ExcelController {
@GetMapping("/download/customers")
public void downloadCustomers(HttpServletResponse response) {
// π‘ Library preserves user-set headers (security tokens, etc.)
// response.setHeader("X-Custom-Token", securityToken); // This header will be kept
List<CustomerDTO> customers = customerService.getAllCustomers();
// Download immediately in browser using Fluent API
ExcelExporter.excel(response)
.fileName("customers.xlsx")
.write(customers); // Return value (final filename) can be ignored
// Actual download: customers.xlsx (explicit filename - no timestamp)
// π Headers automatically set by library:
// - Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
// - Content-Disposition: attachment; filename="..."
// - Cache-Control: no-store, no-cache (only if user hasn't set it)
}
}// Specify filename
try (FileOutputStream fos = new FileOutputStream("output.xlsx")) {
List<CustomerDTO> customers = customerService.getCustomers();
String fileName = ExcelExporter.excel(fos)
.fileName("customers.xlsx")
.write(customers);
System.out.println("Created: " + fileName);
// Output: Created: customers.xlsx (explicit filename - no timestamp)
}// Auto-generates "download_yyyyMMdd_HHmmss.xlsx" if fileName() is not called
try (FileOutputStream fos = new FileOutputStream("output.xlsx")) {
List<CustomerDTO> customers = customerService.getCustomers();
String fileName = ExcelExporter.excel(fos)
.write(customers); // No fileName() call β auto-generated
System.out.println("Created: " + fileName);
// Output: Created: download_20250108_143025.xlsx
}// Generate in memory and return as byte array
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ExcelExporter.excel(baos)
.fileName("customers.xlsx")
.write(customers);
byte[] excelBytes = baos.toByteArray();
// Can send to other APIs or save to DB
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=customers.xlsx")
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(excelBytes);Create reusable styles by extending CustomExcelCellStyle:
Example: Currency Style
import io.github.takoeats.excelannotator.style.CustomExcelCellStyle;
import io.github.takoeats.excelannotator.style.ExcelCellStyleConfigurer;
import org.apache.poi.ss.usermodel.HorizontalAlignment;
import org.apache.poi.ss.usermodel.VerticalAlignment;
public class CurrencyStyle extends CustomExcelCellStyle {
@Override
protected void configure(ExcelCellStyleConfigurer configurer) {
configurer
.dataFormat("β©#,##0") // or "$#,##0" for USD
.alignment(HorizontalAlignment.RIGHT, VerticalAlignment.CENTER);
}
}Example: Date Style
public class DateOnlyStyle extends CustomExcelCellStyle {
@Override
protected void configure(ExcelCellStyleConfigurer configurer) {
configurer
.dataFormat("yyyy-MM-dd")
.alignment(HorizontalAlignment.CENTER, VerticalAlignment.CENTER);
}
}Example: Percentage Style
public class PercentageStyle extends CustomExcelCellStyle {
@Override
protected void configure(ExcelCellStyleConfigurer configurer) {
configurer
.dataFormat("0.00%")
.alignment(HorizontalAlignment.RIGHT, VerticalAlignment.CENTER);
}
}Example: Alert Style
import io.github.takoeats.excelannotator.style.FontStyle;
public class CriticalAlertStyle extends CustomExcelCellStyle {
@Override
protected void configure(ExcelCellStyleConfigurer configurer) {
configurer
.backgroundColor(220, 20, 60) // Crimson
.fontColor(255, 255, 255) // White
.font("Arial", 11, FontStyle.BOLD)
.alignment(HorizontalAlignment.CENTER, VerticalAlignment.CENTER);
}
}Important: Use appropriate field types for formatted columns:
- Currency/Numeric styles β
BigDecimal,Integer,Long,Double- Date styles β
LocalDate,LocalDateTime,Date- Percentage styles β
DoubleorBigDecimal
Usage in DTO:
@ExcelSheet("Sales Records")
public class SalesDTO {
@ExcelColumn(
header = "Amount",
order = 1,
columnStyle = CurrencyStyle.class
)
private BigDecimal amount;
@ExcelColumn(
header = "Sale Date",
order = 2,
columnStyle = DateOnlyStyle.class
)
private LocalDate saleDate;
@ExcelColumn(
header = "Achievement Rate",
order = 3,
columnStyle = PercentageStyle.class
)
private Double achievementRate;
}import io.github.takoeats.excelannotator.annotation.ConditionalStyle;
@ExcelSheet("Financial Report")
public class FinanceDTO {
@ExcelColumn(
header = "Profit/Loss",
order = 1,
conditionalStyles = {
@ConditionalStyle(
when = "value < 0", // When negative
style = CriticalAlertStyle.class, // Red background
priority = 10
)
}
)
private BigDecimal profitLoss;
}@ExcelColumn(
header = "Amount",
order = 2,
conditionalStyles = {
// Highest priority: negative β red
@ConditionalStyle(
when = "value < 0",
style = CriticalAlertStyle.class,
priority = 30
),
// Medium: over million β yellow highlight
@ConditionalStyle(
when = "value > 1000000",
style = HighlightStyle.class,
priority = 20
),
// Low: normal range β green
@ConditionalStyle(
when = "value > 0 && value <= 1000000",
style = SignatureStyle.class,
priority = 10
)
}
)
private BigDecimal amount;@ExcelColumn(
header = "Status",
order = 3,
conditionalStyles = {
@ConditionalStyle(
when = "value equals 'Complete' || value equals 'Approved'",
style = SignatureStyle.class,
priority = 10
),
@ConditionalStyle(
when = "value contains 'In Progress'",
style = HighlightStyle.class,
priority = 9
)
}
)
private String status;Supported Expressions:
| Operator | Example | Description |
|---|---|---|
< <= > >= |
value > 100 |
Numeric comparison |
== equals |
value equals 100 |
Equality |
!= |
value != 0 |
Inequality |
between |
value between 10 and 100 |
Range (10 β€ value β€ 100) |
contains |
value contains 'text' |
String contains |
is_null |
value is_null |
Null check |
is_empty |
value is_empty |
Empty string |
is_negative |
value is_negative |
Negative number |
&& || ! |
value > 0 && value < 100 |
Logical operators |
@PostMapping("/download/report")
public void downloadMultiSheetReport(HttpServletResponse response) {
Map<String, List<?>> sheetData = new LinkedHashMap<>();
// Keys are identifiers, actual sheet names come from @ExcelSheet.value()
sheetData.put("customers", customerService.getCustomers()); // @ExcelSheet("Customers")
sheetData.put("orders", orderService.getOrders()); // @ExcelSheet("Orders")
sheetData.put("products", productService.getProducts()); // @ExcelSheet("Products")
// Use Fluent API with Map
ExcelExporter.excel(response)
.fileName("integrated_report.xlsx")
.write(sheetData); // Return value (final filename) can be ignored
}Result: Excel file with 3 sheets
- Sheet1: "Customers"
- Sheet2: "Orders"
- Sheet3: "Products"
try (FileOutputStream fos = new FileOutputStream("report.xlsx")) {
Map<String, List<?>> sheetData = new LinkedHashMap<>();
sheetData.put("customers", customerList);
sheetData.put("orders", orderList);
// Fluent API with OutputStream + Map
String fileName = ExcelExporter.excel(fos)
.fileName("report.xlsx")
.write(sheetData);
System.out.println("Multi-sheet created: " + fileName);
}// CustomerBasicDTO
@ExcelSheet("Customers")
public class CustomerBasicDTO {
@ExcelColumn(header = "ID", order = 1)
private Long id;
@ExcelColumn(header = "Name", order = 2)
private String name;
}
// CustomerExtraDTO
@ExcelSheet("Customers") // Same sheet name!
public class CustomerExtraDTO {
@ExcelColumn(header = "Email", order = 3)
private String email;
@ExcelColumn(header = "Phone", order = 4)
private String phone;
}
// Usage
Map<String, List<?>> data = new LinkedHashMap<>();
data.put("basic", customerBasicList);
data.put("extra", customerExtraList);
ExcelExporter.excel(response)
.fileName("customers.xlsx")
.write(data); // Return value (final filename) can be ignoredResult: Single sheet "Customers" with 4 columns (ID, Name, Email, Phone)
@PostMapping("/download/large-customers")
public void downloadLargeCustomers(HttpServletResponse response) {
// JPA Repository returns Stream (cursor-based)
Stream<CustomerDTO> customerStream = customerRepository.streamAllCustomers();
// Use Fluent API with Stream
ExcelExporter.excel(response)
.fileName("large_customers.xlsx")
.write(customerStream); // Return value (final filename) can be ignored
}Benefits:
- β Can handle 1M+ rows
- β Keeps only 100 rows in memory (SXSSF)
- β Doesn't load entire dataset into memory
// Specify filename
try (FileOutputStream fos = new FileOutputStream("customers.xlsx");
Stream<CustomerDTO> stream = customerRepository.streamAll()) {
String fileName = ExcelExporter.excel(fos)
.fileName("customers.xlsx")
.write(stream);
System.out.println("Large file created: " + fileName);
}
// Auto-generate filename
try (FileOutputStream fos = new FileOutputStream("customers.xlsx");
Stream<CustomerDTO> stream = customerRepository.streamAll()) {
String fileName = ExcelExporter.excel(fos)
.write(stream); // No fileName() call β auto-generated
System.out.println("Large file created: " + fileName);
// Output: Large file created: download_20250108_143025.xlsx
}@PostMapping("/download/large-report")
public void downloadLargeReport(HttpServletResponse response) {
Map<String, Stream<?>> sheetStreams = new LinkedHashMap<>();
// Provide each sheet as Stream
sheetStreams.put("customers", customerRepository.streamAll());
sheetStreams.put("orders", orderRepository.streamAll());
// Fluent API with Stream Map
ExcelExporter.excel(response)
.fileName("large_report.xlsx")
.write(sheetStreams); // Return value (final filename) can be ignored
}// Repository
public interface CustomerRepository extends JpaRepository<CustomerEntity, Long> {
@Query("SELECT c FROM CustomerEntity c WHERE c.active = true")
@QueryHints(@QueryHint(name = HINT_FETCH_SIZE, value = "100"))
Stream<CustomerEntity> streamActiveCustomers();
}
// Service
@Service
@Transactional(readOnly = true)
public class CustomerService {
public void exportActiveCustomers(HttpServletResponse response) {
try (Stream<CustomerEntity> stream = customerRepository.streamActiveCustomers()) {
Stream<CustomerDTO> dtoStream = stream.map(this::toDTO);
ExcelExporter.excel(response)
.fileName("customers.xlsx")
.write(dtoStream); // Return value (final filename) can be ignored
}
}
}| Data Size | Recommended API | Reason |
|---|---|---|
| < 10K rows | excelFromList() |
Simple, fast |
| 10K~1M rows | excelFromStream() |
Memory efficient |
| > 1M rows | excelFromStream() required |
List API has 1M limit |
Generate CSV files with annotations. Fully compliant with RFC 4180 standard.
@PostMapping("/download/customers-csv")
public void downloadCustomersAsCsv(HttpServletResponse response) {
List<CustomerDTO> customers = customerService.getAllCustomers();
// CSV download using Fluent API (uses same DTO as Excel)
ExcelExporter.csv(response)
.fileName("customers.csv")
.write(customers); // Return value (final filename) can be ignored
// Actual download: customers.csv (explicit filename - no timestamp)
}try (FileOutputStream fos = new FileOutputStream("customers.csv")) {
List<CustomerDTO> customers = customerService.getCustomers();
String fileName = ExcelExporter.csv(fos)
.fileName("customers.csv")
.write(customers);
System.out.println("CSV created: " + fileName);
}@PostMapping("/download/large-customers-csv")
public void downloadLargeCustomersAsCsv(HttpServletResponse response) {
Stream<CustomerDTO> stream = customerRepository.streamAllCustomers();
// Large CSV streaming using Fluent API
ExcelExporter.csv(response)
.fileName("large_customers.csv")
.write(stream); // Return value (final filename) can be ignored
}CSV Format Example:
"Name","Age","Salary"
"Alice","30","123.45"
"Bob","40","67.89"
"Charlie","25","50000.00"RFC 4180 Compliance:
- All fields enclosed in double quotes (
") - Double quotes within fields escaped as
"" - Record separator is CRLF (
\r\n) - Preserves newlines and commas within fields
- UTF-8 BOM included (Excel compatibility)
Excel vs CSV Selection Criteria:
| Criteria | Excel | CSV |
|---|---|---|
| Styling needed | β | β |
| Conditional formatting | β | β |
| Multi-sheet | β | β |
| Simple data exchange | βͺ | β |
| File size | Large | Small |
| Compatibility | Medium | High |
| Processing speed | Medium | Fast |
Mask sensitive personal information (PII) automatically with built-in presets.
| Preset | Input Example | Output Example | Use Case |
|---|---|---|---|
PHONE |
010-1234-5678 | 010-****-5678 | Phone numbers |
EMAIL |
user@example.com | u***@example.com | Email addresses |
SSN |
123456-1234567 | 123456-******* | Social Security Numbers |
NAME |
νκΈΈλ | ν*λ | Personal names |
CREDIT_CARD |
1234-5678-9012-3456 | --****-3456 | Credit card numbers |
ACCOUNT_NUMBER |
110-123-456789 | 110-***-***789 | Bank account numbers |
ADDRESS |
μμΈμ κ°λ¨κ΅¬ ν ν€λλ‘ 123 | μμΈμ κ°λ¨κ΅¬ *** | Street addresses |
ZIP_CODE |
12345 | 123** | Postal codes |
IP_ADDRESS |
192.168.1.100 | 192.168.. | IP addresses |
PASSPORT |
M12345678 | M12***678 | Passport numbers |
LICENSE_PLATE |
12κ°3456 | 12κ°**56 | Vehicle license plates |
PARTIAL_LEFT |
ABC12345 | ****2345 | Mask left, show right 4 |
PARTIAL_RIGHT |
ABC12345 | ABC1**** | Mask right, show left 4 |
MIDDLE |
ABC12345 | AB****45 | Mask middle, show sides |
import io.github.takoeats.excelannotator.masking.Masking;
@ExcelSheet("Customer Information")
public class CustomerDTO {
@ExcelColumn(header = "Name", order = 1, masking = Masking.NAME)
private String name;
@ExcelColumn(header = "Phone", order = 2, masking = Masking.PHONE)
private String phoneNumber;
@ExcelColumn(header = "Email", order = 3, masking = Masking.EMAIL)
private String email;
@ExcelColumn(header = "SSN", order = 4, masking = Masking.SSN)
private String socialSecurityNumber;
}@ExcelSheet("User Data Export")
public class UserExportDTO {
@ExcelColumn(header = "User ID", order = 1)
private Long userId; // No masking
@ExcelColumn(header = "Name", order = 2, masking = Masking.NAME)
private String fullName; // νκΈΈλ β ν*λ
@ExcelColumn(header = "Email", order = 3, masking = Masking.EMAIL)
private String email; // user@domain.com β u***@domain.com
@ExcelColumn(header = "Phone", order = 4, masking = Masking.PHONE)
private String phone; // 010-1234-5678 β 010-****-5678
@ExcelColumn(header = "Address", order = 5, masking = Masking.ADDRESS)
private String address; // μμΈμ κ°λ¨κ΅¬ ν
ν€λλ‘ 123 β μμΈμ κ°λ¨κ΅¬ ***
}
// Controller
@PostMapping("/export/users")
public void exportUsers(HttpServletResponse response) {
List<UserExportDTO> users = userService.getAllUsers();
ExcelExporter.excel(response)
.fileName("user_data.xlsx")
.write(users);
// Downloaded file contains masked sensitive data
}@ExcelSheet("Financial Report")
public class TransactionDTO {
@ExcelColumn(header = "Account Number", order = 1, masking = Masking.ACCOUNT_NUMBER)
private String accountNumber;
@ExcelColumn(
header = "Amount",
order = 2,
conditionalStyles = @ConditionalStyle(
when = "value < 0",
style = RedBackgroundStyle.class
)
)
private BigDecimal amount;
@ExcelColumn(header = "Card Number", order = 3, masking = Masking.CREDIT_CARD)
private String cardNumber;
}Important Notes:
- Masking only applies to String fields
- Non-string types (Integer, Date, etc.) are ignored
- For custom masking logic, apply masking before setting DTO values
nulland empty strings are handled gracefully (no errors)
Dedicated API that separates query logic and transformation logic for improved reusability.
// HttpServletResponse version
ExcelExporter.excelFromList(
HttpServletResponse response,
String fileName,
Q queryParams, // Query parameter object
ExcelDataProvider<Q, R> dataProvider, // Data fetch function
Function<R, E> converter // Entity β DTO conversion function
)// 1. Query Parameters DTO
@Data
public class CustomerSearchRequest {
private LocalDate startDate;
private LocalDate endDate;
private String customerType;
}
// 2. Service Layer
@Service
public class CustomerService {
// Data Provider: Complex query logic
public List<CustomerEntity> searchCustomers(CustomerSearchRequest request) {
return customerRepository.findByDateRangeAndType(
request.getStartDate(),
request.getEndDate(),
request.getCustomerType()
);
}
// Converter: Entity β DTO transformation
public CustomerDTO toDTO(CustomerEntity entity) {
return CustomerDTO.builder()
.customerId(entity.getId())
.customerName(entity.getName())
.email(entity.getEmail())
.build();
}
}
// 3. Controller
@PostMapping("/download/customers/search")
public void downloadSearchResults(
@RequestBody CustomerSearchRequest request,
HttpServletResponse response
) {
// Separate three concerns: query, fetch, transform using Fluent API
ExcelExporter.excel(response)
.fileName("search_results.xlsx")
.write(
request, // Q: Query params
customerService::searchCustomers, // ExcelDataProvider<Q, R>
customerService::toDTO // Function<R, E>
); // Return value (final filename) can be ignored
}Benefits:
- β
Reusable query logic (can use
searchCustomers()in other APIs) - β
Reusable transform logic (can use
toDTO()in other APIs) - β Testability (independently test each function)
- β Code readability (separation of concerns)
Automatically convert all fields to Excel columns without manually adding @ExcelColumn to each field.
import io.github.takoeats.excelannotator.annotation.ExcelSheet;
@ExcelSheet(value = "Customers", autoColumn = true)
public class CustomerDTO {
private String name; // Auto-included: header = "name", order = 1
private Integer age; // Auto-included: header = "age", order = 2
private String email; // Auto-included: header = "email", order = 3
private Double salary; // Auto-included: header = "salary", order = 4
}Result:
- All fields are automatically exported to Excel
- Header names use field names
- Column order follows field declaration order
import io.github.takoeats.excelannotator.annotation.ExcelColumn;
@ExcelSheet(value = "Users", autoColumn = true)
public class UserDTO {
private String username; // Auto-included
@ExcelColumn(exclude = true)
private String password; // Excluded from export
private String email; // Auto-included
private Integer age; // Auto-included
}Result: Only username, email, and age are exported (password is excluded)
@ExcelSheet(value = "Products", autoColumn = true)
public class ProductDTO {
@ExcelColumn(header = "Full Name", order = 1)
private String name; // Explicit annotation takes priority
private Integer age; // Auto: header = "age", order = 2
@ExcelColumn(header = "Email Address", order = 3)
private String email; // Explicit annotation takes priority
private String phone; // Auto: header = "phone", order = 4
@ExcelColumn(exclude = true)
private String internalId; // Excluded
}Result:
- Fields with
@ExcelColumnuse the annotation settings - Fields without annotation are auto-generated
exclude = truefields are skipped
β Good for:
- Simple DTOs with many fields
- Quick prototyping
- Internal reports where field names are acceptable as headers
β Not recommended for:
- User-facing exports requiring professional headers
- Complex styling requirements per column
- When precise column ordering across multiple DTOs is needed
π‘ Tip: You can start with autoColumn = true during development, then add explicit @ExcelColumn annotations as
your requirements become more specific.
The library determines column width in the following priority order:
- Explicit
@ExcelColumn(width=...)specification (highest priority) - Style's
autoWidth()configuration - Style's
width(...)configuration - Default value (100 pixels)
@ExcelSheet("Customers")
public class CustomerDTO {
@ExcelColumn(
header = "Customer Name",
order = 1,
width = 150, // Explicitly specify 150px (always applied)
columnStyle = MyCustomStyle.class // Style width is ignored
)
private String customerName;
@ExcelColumn(
header = "Email",
order = 2,
columnStyle = AutoWidthStyle.class // Uses style's autoWidth()
)
private String email;
@ExcelColumn(
header = "Phone",
order = 3
// No width, no style β default 100px
)
private String phone;
}The library automatically applies default styles when no custom style is specified:
| Field Type | Default Style | Behavior |
|---|---|---|
| Numeric types (Integer, Long, BigDecimal, etc.) | DefaultNumberStyle |
Right-aligned, #,##0 format |
| Other types (String, Date, etc.) | DefaultColumnStyle |
Left-aligned, no special format |
| Headers (all columns) | DefaultHeaderStyle |
Bold, center-aligned |
Example:
@ExcelColumn(header = "Amount", order = 1)
private BigDecimal amount; // Automatically uses DefaultNumberStyle
@ExcelColumn(header = "Name", order = 2)
private String name; // Automatically uses DefaultColumnStyleOverriding Defaults:
@ExcelColumn(
header = "Amount",
order = 1,
columnStyle = CurrencyStyle.class // Override DefaultNumberStyle
)
private BigDecimal amount;Create professional-looking Excel files with grouped column headers:
@ExcelSheet("Sales Report")
public class SalesDTO {
@ExcelColumn(
header = "Name",
order = 1,
mergeHeader = "Customer Info" // Group header
)
private String customerName;
@ExcelColumn(
header = "Email",
order = 2,
mergeHeader = "Customer Info" // Same group
)
private String email;
@ExcelColumn(header = "Amount", order = 3) // No merge β auto vertical merge
private BigDecimal amount;
}Result:
Row 0: [ Customer Info ] [ ]
Row 1: [ Name | Email ] [Amount]
Data: [Alice | a@ex.com] [ $100 ]
@ExcelSheet("Employee Report")
public class EmployeeDTO {
@ExcelColumn(header = "Name", order = 1, mergeHeader = "Personal")
private String name;
@ExcelColumn(header = "Age", order = 2, mergeHeader = "Personal")
private Integer age;
@ExcelColumn(header = "Street", order = 3, mergeHeader = "Address")
private String street;
@ExcelColumn(header = "City", order = 4, mergeHeader = "Address")
private String city;
@ExcelColumn(header = "Salary", order = 5) // No merge group
private BigDecimal salary;
}Result:
Row 0: [ Personal ] [ Address ] [ ]
Row 1: [Name | Age] [St. | City] [Salary]
public class BlueHeaderStyle extends CustomExcelCellStyle {
@Override
protected void configure(ExcelCellStyleConfigurer configurer) {
configurer
.backgroundColor(ExcelColors.lightBlue())
.fontColor(ExcelColors.darkBlue());
}
}
@ExcelSheet("Report")
public class ReportDTO {
@ExcelColumn(
header = "Q1",
order = 1,
mergeHeader = "2024 Sales",
mergeHeaderStyle = BlueHeaderStyle.class // Custom style for merge header
)
private BigDecimal q1Sales;
@ExcelColumn(
header = "Q2",
order = 2,
mergeHeader = "2024 Sales"
)
private BigDecimal q2Sales;
}Important:
- β Columns in a merge group must have consecutive order values
- β Gaps in order will throw
MERGE_HEADER_ORDER_GAPexception - β
Columns without
mergeHeaderare automatically merged vertically (1 column, 2 rows)
// β Invalid: Gap in order
@ExcelColumn(order = 1, mergeHeader = "Group") // β
@ExcelColumn(order = 2) // β Gap!
@ExcelColumn(order = 3, mergeHeader = "Group") // β Error!
// β
Valid: Consecutive orders
@ExcelColumn(order = 1, mergeHeader = "Group") // β
@ExcelColumn(order = 2, mergeHeader = "Group") // β
@ExcelColumn(order = 3) // β@ExcelSheet(value = "Data", hasHeader = false) // Omit header row
public class DataDTO {
@ExcelColumn(header = "ID", order = 1) // header is required but not displayed
private Long id;
@ExcelColumn(header = "Name", order = 2)
private String name;
}@ExcelColumn(
header = "Total Amount",
order = 1,
headerStyle = MyCustomHeaderStyle.class, // Header cell style
columnStyle = CurrencyStyle.class // Data cell style
)
private BigDecimal totalAmount;@ExcelSheet(value = "Summary", order = 1) // First sheet
public class SummaryDTO { ... }
@ExcelSheet(value = "Details", order = 2) // Second sheet
public class DetailDTO { ... }
@ExcelSheet(value = "Reference") // No order β positioned first
public class ReferenceDTO { ... }Sorting Rules:
- Sheets without
ordercome first (in input order) - Sheets with
ordersorted in ascending order
Result Sheet Order: Reference β Summary β Details
A: Choose based on data size.
- < 10K rows:
excelFromList()(simple, fast) - > 10K rows:
excelFromStream()(memory efficient) - > 1M rows:
excelFromStream()required (List has 1M limit)
A: Timestamps are only added to default filenames to prevent collisions.
// Explicit filename β no timestamp
ExcelExporter.excelFromList(response, "report.xlsx", data);
// Actual download: report.xlsx
// Default filename β timestamp added
ExcelExporter.excelFromList(outputStream, data); // or "download"
// Result: download_20250119_143025.xlsx
// Already has timestamp pattern β no duplicate
ExcelExporter.excelFromList(response, "report_20251219_132153.xlsx", data);
// Actual download: report_20251219_132153.xlsxA: Higher priority values take precedence.
@ExcelColumn(
conditionalStyles = {
@ConditionalStyle(when = "value < 0", style = RedStyle.class, priority = 30),
@ConditionalStyle(when = "value < -1000", style = DarkRedStyle.class, priority = 20)
}
)When value is -2000:
- Both conditions match
- priority 30 > 20 β
RedStyleapplied
A: Fields without @ExcelColumn are not included in Excel.
@ExcelSheet("Customers")
public class CustomerDTO {
@ExcelColumn(header = "ID", order = 1)
private Long id;
private String internalCode; // Not included in Excel
}A: No. Empty lists/streams throw ExcelExporterException (E001).
Solution:
List<CustomerDTO> customers = customerService.getCustomers();
if (customers.isEmpty()) {
throw new CustomException("No customers found");
}
ExcelExporter.excel(response)
.fileName("customers.xlsx")
.write(customers);A: DTOs with the same @ExcelSheet.value() merge into one sheet.
// DTO A: @ExcelSheet("Customers") + order=1,2
// DTO B: @ExcelSheet("Customers") + order=3,4
// Result: Single sheet "Customers" with 4 columns (order: 1,2,3,4)A: The library automatically caches and deduplicates styles.
Advice:
- Minimize conditional styles (consolidate ranges)
- Merge similar styles
A: Yes, it's thread-safe.
@Async
public void exportCustomers(Long userId, HttpServletResponse response) {
List<CustomerDTO> customers = customerService.getCustomersByUser(userId);
ExcelExporter.excel(response)
.fileName("customers.xlsx")
.write(customers);
}| Code | Message | Solution |
|---|---|---|
| E001 | Empty data collection | Check for empty data before processing |
| E005 | No @ExcelSheet annotation | Add @ExcelSheet to DTO |
| E006 | No @ExcelColumn fields | Add at least 1 @ExcelColumn field |
| E016 | Exceeded maximum rows for List API | Use Stream API |
| E017 | Stream already consumed | Create new stream |
@PostMapping("/download/customers")
public ResponseEntity<?> downloadCustomers(HttpServletResponse response) {
try {
List<CustomerDTO> customers = customerService.getCustomers();
ExcelExporter.excel(response)
.fileName("customers.xlsx")
.write(customers);
return ResponseEntity.ok().build();
} catch (ExcelExporterException ex) {
log.error("Excel export failed: {}", ex.getMessage(), ex);
switch (ex.getCode()) {
case "E001":
return ResponseEntity.badRequest()
.body("No data available.");
case "E016":
return ResponseEntity.badRequest()
.body("Too much data. Please narrow the date range.");
default:
return ResponseEntity.internalServerError()
.body("Excel generation error: " + ex.getMessage());
}
}
}<dependency>
<groupId>io.github.takoeats</groupId>
<artifactId>excel-annotator</artifactId>
<version>2.3.4</version>
</dependency>implementation 'io.github.takoeats:excel-annotator:2.3.4'What is a Shaded JAR?
A shaded JAR bundles all dependencies (Apache POI, Commons Collections, etc.) inside the library and relocates them to a different package namespace. This prevents version conflicts when your project uses different versions of the same libraries.
Why Use Shaded JAR?
If your project already uses Apache POI or related libraries, you may encounter:
java.lang.NoSuchMethodError: org.apache.poi.ss.usermodel.Workbook.createSheet()
ClassNotFoundException: org.apache.xmlbeans.XmlObject
The shaded JAR solves this by isolating excel-annotator's dependencies:
org.apache.poiβio.github.takoeats.shaded.poiorg.apache.commons.collections4βio.github.takoeats.shaded.commons.collections4org.apache.commons.compressβio.github.takoeats.shaded.commons.compressorg.apache.xmlbeansβio.github.takoeats.shaded.xmlbeans
How to Use Shaded JAR
Add -shaded to your dependency:
Maven:
<dependency>
<groupId>io.github.takoeats</groupId>
<artifactId>excel-annotator-shaded</artifactId>
<version>2.3.4</version>
</dependency>Gradle:
implementation 'io.github.takoeats:excel-annotator:2.3.4:shaded'When to Use Each JAR
| Scenario | Recommended JAR | Reason |
|---|---|---|
| No Apache POI in project | Regular JAR | Smaller file size, shared dependencies |
| 5.4.0 POI ooxml in project | Regular JAR | Smaller file size, shared dependencies |
| Project uses different POI version | Shaded JAR | Prevents version conflicts |
| Dependency conflicts occur | Shaded JAR | Complete isolation |
| Corporate proxy/repository issues | Shaded JAR | Single self-contained artifact |
Important Notes:
- β API remains identical - No code changes needed when switching between regular and shaded JAR
- β Shaded JAR is larger (~15MB vs ~500KB) due to bundled dependencies
- β Both JARs are published to Maven Central for every release
| Library | Version | Description |
|---|---|---|
| Apache POI | 5.4.0 | Excel file manipulation |
| SLF4J API | 2.0.17 | Logging API |
| Servlet API | 3.1.0 (provided) | HttpServletResponse |
| Lombok | 1.18.30 (provided) | Boilerplate reduction |
@RestController
@RequestMapping("/api/excel")
@RequiredArgsConstructor
public class ExcelController {
private final CustomerService customerService;
@GetMapping("/customers")
public void downloadCustomers(HttpServletResponse response) {
List<CustomerDTO> customers = customerService.getAllCustomers();
ExcelExporter.excel(response)
.fileName("customers.xlsx")
.write(customers);
}
@GetMapping("/monthly-report")
public void downloadMonthlyReport(
@RequestParam int year,
@RequestParam int month,
HttpServletResponse response
) {
Map<String, List<?>> report = new LinkedHashMap<>();
report.put("customers", customerService.getCustomersByMonth(year, month));
report.put("orders", orderService.getOrdersByMonth(year, month));
String fileName = String.format("monthly_report_%d_%d.xlsx", year, month);
ExcelExporter.excel(response)
.fileName(fileName)
.write(report);
}
}@Data
@ExcelSheet("Financial Summary")
public class FinancialSummaryDTO {
@ExcelColumn(header = "Category", order = 1)
private String category;
@ExcelColumn(
header = "Amount",
order = 2,
columnStyle = CurrencyStyle.class,
conditionalStyles = {
@ConditionalStyle(
when = "value < 0",
style = CriticalAlertStyle.class,
priority = 30
),
@ConditionalStyle(
when = "value > 10000000",
style = HighlightStyle.class,
priority = 20
)
}
)
private BigDecimal amount;
@ExcelColumn(
header = "Change Rate",
order = 3,
columnStyle = PercentageStyle.class,
conditionalStyles = {
@ConditionalStyle(
when = "value < -0.1", // Below -10%
style = CriticalAlertStyle.class,
priority = 20
),
@ConditionalStyle(
when = "value > 0.2", // Above +20%
style = SignatureStyle.class,
priority = 10
)
}
)
private Double changeRate;
@ExcelColumn(
header = "Completion Status",
order = 4,
columnStyle = BooleanStyle.class,
conditionalStyles = {
@ConditionalStyle(
when = "Conditions.IS_NEGATIVE", // Below -10%
style = CriticalAlertStyle.class,
priority = 20
),
@ConditionalStyle(
when = Conditions.IS_POSITIVE, // Above +20%
style = SignatureStyle.class,
priority = 10
)
}
)
private boolean isCompleted;
}@Service
@RequiredArgsConstructor
public class ExcelBatchService {
private final CustomerRepository customerRepository;
@Transactional(readOnly = true)
public String exportAllCustomers() throws Exception {
String outputPath = "/batch/output/customers.xlsx";
try (FileOutputStream fos = new FileOutputStream(outputPath);
Stream<CustomerEntity> stream = customerRepository.streamAll()) {
Stream<CustomerDTO> dtoStream = stream.map(this::toDTO);
String fileName = ExcelExporter.excel(fos)
.fileName("customers.xlsx")
.write(dtoStream);
log.info("Batch export completed: {}", fileName);
return fileName;
}
}
}The ExcelExporter.excelFromList(response, fileName, data) method sets only the minimum required headers,
respecting user control.
Headers that the library always sets (overwrite):
Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
Content-Disposition: attachment; filename="download.xlsx"; filename*=UTF-8''...Applied only if user hasn't set them:
Cache-Control: no-store, no-cache, must-revalidate, max-age=0Example: Custom Cache-Control
@GetMapping("/download/public-report")
public void downloadPublicReport(HttpServletResponse response) {
// Allow caching if desired
response.setHeader("Cache-Control", "public, max-age=3600");
List<ReportDTO> data = reportService.getPublicData();
ExcelExporter.excel(response)
.fileName("report.xlsx")
.write(data);
// Cache-Control remains "public, max-age=3600"
}The library does not call response.reset(), so all user-set headers are preserved.
Example: Maintaining Security Token Headers
@GetMapping("/download/secure-data")
public void downloadSecureData(HttpServletResponse response) {
// Authentication/security custom headers
response.setHeader("X-Custom-Auth-Token", securityService.generateToken());
response.setHeader("X-Request-ID", requestId);
response.setHeader("X-User-Role", currentUser.getRole());
List<SecureDataDTO> data = secureDataService.getData();
ExcelExporter.excel(response)
.fileName("secure-data.xlsx")
.write(data);
// β
All custom headers are preserved
}- Minimal Intervention: Set only headers essential for Excel generation
- User First: Never remove user-set values
- Container Delegation: Does not call
response.flushBuffer()(Servlet container handles automatically)
User-provided filenames go through whitelist-based validation β sanitization β semantic validation. Risky or meaningless filenames are automatically replaced with safe default filenames.
(Java code example)
ExcelExporter.excelFromList(response, "../../../etc/passwd.xlsx", data);
Processing result
download_20251216_143025.xlsx
Path traversal patterns detected β Immediately blocked without partial sanitization, replaced with default filename.
(Java code example)
ExcelExporter.excelFromList(response, "!!!@@@###", data);
Processing result
download_20251216_143025.xlsx
- All characters removed/replaced, meaning lost
- Only underscores (_) remaining β Default filename applied
Filenames in the following languages are allowed:
- Korean (κ°βν£)
- Japanese (Hiragana, Katakana)
- Chinese (CJK Unified Ideographs)
- Western European (accented characters)
(Java code example)
ExcelExporter.excelFromList(response, "Sales_Report.xlsx", data);
Processing result
Sales_Report.xlsx
Any of the following patterns detected β immediately replaced with default filename:
- Path traversal .., /, , :
- Hidden files Files starting with .
- Control characters \x00β\x1F, \x7F
- URL encoding attacks %2e, %2f, %5c, %00
- OS reserved filenames
- Windows: CON, PRN, AUX, NUL, COM1β9, LPT1β9
- Unix/Linux: null, stdin, stdout, stderr, random, etc.
- Filename length limit Auto-truncated if exceeds 200 characters
- Whitelist-based allowance
- Dangerous patterns immediately blocked without sanitization
- Meaningless results use default filename
- Extension and timestamp added by system after validation
This project is licensed under the Apache-2.0 license.
Please report bugs and feature requests on GitHub Issues.
β Star this project if you find it useful! β
Made with β€οΈ by takoeats