Appearance
Java Annotations Explained from the Ground Up
If you have ever typed @Override above a method, you have already used an annotation. But most developers use annotations for years without really understanding what they are, how they work, or how to build their own. This article covers everything: what annotations actually do, all five predefined annotations, all five meta annotations, how to create custom annotations, and how to read them at runtime using reflection. After you finish reading this, annotations will feel completely natural.
What an Annotation Actually Is
An annotation is metadata that you attach to your Java code. The word metadata means data about data. So an annotation is not your program's logic, it is extra information you are adding to describe your program.
Think of it like a sticky note on a document. The sticky note does not change the document itself. It just gives the reader some extra context: "This section is outdated," or "This part needs review." In the same way, an annotation on a Java class or method adds context that the compiler, the JVM, or other tools can read and act on.
The crucial point is that annotations are completely optional. If you remove @Override from a method that correctly overrides a parent method, the method still works. Nothing breaks. The annotation was just metadata and it is gone. This is the fundamental nature of annotations: they describe your code but they do not change what your code does on their own.
So why bother? Because whoever reads the annotation can add logic based on it. When the compiler sees @Override, it adds the logic of checking whether a matching method signature exists in the parent or interface. When a framework like Spring sees @Controller, it adds the logic of routing HTTP requests to that class. The annotation is the signal; the reader of the annotation provides the behavior.
You read annotations using reflection. Reflection is Java's ability to inspect its own code at runtime. You can load a class, ask it what methods it has, ask a method what annotations are on it, and then make decisions based on those annotations. This is how Spring, Hibernate, JUnit, and every other annotation driven framework in Java actually works under the hood.
Annotations appear in two broad categories. The first is predefined annotations, which Java ships with out of the box. The second is custom annotations, which you define yourself using a special keyword. Within predefined annotations there are two subcategories: those you apply to your regular Java code (classes, methods, fields, parameters), and meta annotations, which you apply to other annotations to configure their behavior.
The Five Annotations You Use on Your Code
@Deprecated
When a class, method, or field is marked @Deprecated, it means the Java team or the library author is saying: we wrote this thing, but we no longer recommend using it. There may be a better replacement available. We will not be fixing bugs in this old version or adding features to it.
java
public class Mobile {
// This method is outdated. Use newDummyMethod() instead.
@Deprecated
public void dummyMethod() {
System.out.println("Old implementation");
}
public void newDummyMethod() {
System.out.println("New and improved implementation");
}
}As soon as you mark something deprecated and someone else tries to call it, the compiler shows a warning in their IDE. The program still compiles and runs, but the warning is there as a nudge: you are using something the author no longer maintains.
The @Deprecated annotation can be placed on constructors, fields, local variables, methods, packages, parameters, and types (classes, interfaces, enums). It is very flexible about where it can go.
@Override
This annotation is the one almost every Java developer sees first. When you place @Override on a method, you are telling the compiler: please verify that an identically named method with the same signature exists in my parent class or in one of the interfaces I implement.
java
interface Bird {
void fly();
}
class Eagle implements Bird {
@Override
public void fly() {
System.out.println("Eagle soars");
}
}Without @Override, if you accidentally spell the method name wrong or use the wrong parameter types, the compiler treats it as a brand new method you invented. Your program compiles, your test passes, but the interface method never actually gets overridden. This is a nasty bug that can take hours to find.
With @Override, the compiler catches it immediately. You get a compile time error telling you no matching method exists. The annotation saved you from the bug.
@Override can only be placed on methods. It has no meaning on fields, constructors, or classes. And notice something important here: the annotation itself has zero effect at runtime. It is checked at compile time and then completely discarded. It never makes it into the .class file. This leads us directly to the concept of retention policy, which we will cover soon.
@SuppressWarnings
The Java compiler is very helpful but sometimes it shows warnings you do not want to see. Maybe you are intentionally using a deprecated method because migrating to the new one would take three months and the old one still works fine. The @SuppressWarnings annotation tells the compiler to stop showing you specific warnings.
java
public class Main {
@SuppressWarnings("deprecation")
public void doSomethingOld() {
Mobile m = new Mobile();
m.dummyMethod(); // using deprecated method, but we know what we are doing
}
@SuppressWarnings("all")
public void silenceEverything() {
// no warnings at all from this method
}
}The value you pass to @SuppressWarnings is a string that names the warning category. Common ones are "deprecation" for deprecated API usage, "unused" for variables you declared but never used, "unchecked" for unchecked cast warnings from generics, and "all" to silence everything at once.
The pitfall with @SuppressWarnings("all") is serious. Some compiler warnings exist to prevent real runtime exceptions. If you silence all warnings, you might also silence one that was trying to warn you about a divide by zero operation or an unsafe cast that would cause a ClassCastException at runtime. Your code compiles cleanly and fails spectacularly in production. The rule is: suppress only the specific warning you have consciously verified as safe. Be precise about what you silence.
You can place @SuppressWarnings on fields, methods, parameters, constructors, local variables, and types.
@FunctionalInterface
A functional interface is an interface that has exactly one abstract method. This is the contract that makes lambda expressions work in Java. When you write Runnable r = () -> System.out.println("hello"), that works because Runnable has exactly one abstract method.
The @FunctionalInterface annotation enforces this constraint at compile time.
java
@FunctionalInterface
interface Processor {
void process(String data);
// Adding a second abstract method causes a compile error immediately:
// void processMore(String data); // ERROR: Invalid '@FunctionalInterface' annotation
}Without the annotation, you could accidentally add a second abstract method to your interface and break every lambda that uses it across your entire codebase. With the annotation, the compiler tells you immediately at the point of the mistake.
The annotation goes on interfaces. It can technically go on classes and enums because @Target for this annotation is TYPE, but it only makes meaningful semantic sense on interfaces.
@SafeVarargs
This one requires a bit of setup to understand properly.
Variable arguments (varargs) let you write a method that accepts any number of parameters of the same type. You define them with three dots:
java
public static void printAll(String... values) {
for (String v : values) {
System.out.println(v);
}
}You can call this method with zero arguments, one argument, or ten arguments. Internally, Java converts the varargs into an array.
Now here is where heap pollution comes in. Heap pollution means an object reference that is supposed to point to one type of object ends up pointing to a different type. For example, a List<Integer> reference that actually points to a List<String> object. This breaks Java's type safety in a way the compiler cannot always catch.
Varargs create an opening for heap pollution because the internal array that Java creates from them is a raw array, and arrays in Java are covariant. That means you can assign any array to an Object[] variable and then put anything into any slot. Here is an example of how this goes wrong:
java
public static void printLogValues(List<Integer>... logNumberList) {
Object[] objectList = logNumberList; // legal due to array covariance
List<String> stringValues = new ArrayList<>();
stringValues.add("hello");
objectList[0] = stringValues; // putting a List<String> where a List<Integer> should be
// Now logNumberList[0] is actually a List<String>
// Calling logNumberList[0].get(0) and treating it as Integer causes ClassCastException
}The compiler warns you about this when your varargs method uses parameterized types: "Possible heap pollution from parameterized vararg type." If you have actually analyzed your method and you know the heap pollution cannot happen in practice, you use @SafeVarargs to tell the compiler you have verified this and it should stop warning you.
java
@SafeVarargs
public static void printLogValues(List<Integer>... logNumberList) {
for (List<Integer> list : logNumberList) {
System.out.println(list);
}
}There is an important constraint: @SafeVarargs can only be applied to methods that are static, final, or (from Java 9 onward) private. Why this restriction? Because if a method could be overridden in a subclass, the subclass might drop the @SafeVarargs annotation and introduce the unsafe heap pollution behavior without the caller knowing. A static, final, or private method cannot be overridden, so the guarantee is solid.
Meta Annotations: Annotations That Configure Other Annotations
Now we move to a layer deeper. Meta annotations are annotations that you place on other annotation declarations. They configure where the annotation can be used and how long it lives.
@Target
@Target specifies where an annotation is allowed to appear. Without @Target, an annotation can technically be placed anywhere. With @Target, you restrict it.
Look at how @Override is defined in the Java source code:
java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.SOURCE)
public @interface Override {
}The @Target(ElementType.METHOD) declaration means you can only put @Override on methods. If you try to put it on a field, you get a compile error.
@SafeVarargs is allowed on both constructors and methods:
java
@Target({ElementType.CONSTRUCTOR, ElementType.METHOD})
public @interface SafeVarargs {
}The possible values of ElementType are:
TYPE means the annotation can go on a class, interface, or enum declaration. FIELD means it can go on a field. METHOD means methods. PARAMETER means the parameters of a method or constructor. CONSTRUCTOR means constructors. LOCAL_VARIABLE means local variables inside methods. ANNOTATION_TYPE means the annotation itself can be placed on another annotation, which is exactly what makes something a meta annotation. PACKAGE means package declarations. TYPE_PARAMETER means generic type parameters. TYPE_USE (introduced in Java 8) means anywhere a type appears, including inside expressions and declarations.
When you see that a meta annotation has ElementType.ANNOTATION_TYPE as its target, that is how you know it can go on another annotation. @Target, @Retention, @Documented, @Inherited, and @Repeatable all have ANNOTATION_TYPE in their targets.
@Retention
This is one of the most important meta annotations and also one of the most common sources of bugs among developers who are new to creating their own annotations.
@Retention tells Java how long the annotation information should be kept. There are three levels:
RetentionPolicy.SOURCE means the annotation exists only in your .java source file. When the compiler compiles your code, it discards the annotation entirely. It never appears in the .class file. @Override and @SuppressWarnings both use SOURCE retention because the compiler processes them and then they are done. There is no reason to keep them around after compilation.
RetentionPolicy.CLASS means the annotation is written into the .class file. However, when the JVM loads the class and your program runs, the JVM ignores the annotation. It is there on disk in the bytecode but completely invisible to running code. This is the default retention level when you do not specify @Retention at all. Bytecode processing tools and certain build tools use CLASS retention.
RetentionPolicy.RUNTIME means the annotation is written into the .class file AND the JVM makes it available while the program is running. This means you can use reflection to read it at runtime. This is what every framework annotation needs. @Controller in Spring, @Entity in Hibernate, @Test in JUnit, all of these use RUNTIME retention because the framework needs to inspect your classes while the program runs.
Here is the classic bug that trips up almost everyone when they first write a custom annotation:
java
// WRONG: no @Retention means the default CLASS retention applies
// The JVM ignores this annotation at runtime, so reflection cannot find it
@interface MyAnnotation {
}
@MyAnnotation
class TestClass {
}
public class Main {
public static void main(String[] args) {
MyAnnotation a = new TestClass().getClass().getAnnotation(MyAnnotation.class);
System.out.println(a); // prints: null (annotation not found!)
}
}java
// CORRECT: RUNTIME retention lets reflection find the annotation
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@interface MyAnnotation {
}
@MyAnnotation
class TestClass {
}
public class Main {
public static void main(String[] args) {
MyAnnotation a = new TestClass().getClass().getAnnotation(MyAnnotation.class);
System.out.println(a); // prints: @MyAnnotation() (found it!)
}
}The fix is simply adding @Retention(RetentionPolicy.RUNTIME). But beginners get burned by this repeatedly because the code compiles perfectly, the annotation appears to be on the class, and then getAnnotation() returns null and there is no error message explaining why.
@Documented
By default, annotations do not appear in the Javadoc output that tools like IntelliJ and Eclipse can generate for your code. When you generate documentation for a class that has @Override on one of its methods, the generated doc does not mention @Override at all.
If you want your annotation to show up in generated Javadoc, mark it with @Documented. The @SafeVarargs annotation uses @Documented, which is why when you generate docs for a method annotated with it, the annotation appears in the output. @Override does not use @Documented, so it stays invisible in the generated documentation.
This matters when you are building a library. If your annotation carries semantic meaning that users of your library need to know about (perhaps it signals that a method is thread safe, or that a class requires special initialization), marking the annotation with @Documented ensures that information appears in the API docs automatically.
@Inherited
By default, if you annotate a parent class with a custom annotation, the child class does not inherit that annotation. When you use reflection to ask the child class whether it has the annotation, you get null.
java
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@interface Company {
String name();
}
@Company(name = "TechCorp")
class Employee {
}
class Manager extends Employee {
// No @Company annotation here
}
public class Main {
public static void main(String[] args) {
// Without @Inherited on the Company annotation:
Company c = new Manager().getClass().getAnnotation(Company.class);
System.out.println(c); // null (Manager did not inherit it)
}
}When you add @Inherited to the annotation definition, the child class automatically picks up the annotation from the parent:
java
@Inherited
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@interface Company {
String name();
}
@Company(name = "TechCorp")
class Employee {
}
class Manager extends Employee {
}
public class Main {
public static void main(String[] args) {
Company c = new Manager().getClass().getAnnotation(Company.class);
System.out.println(c.name()); // TechCorp (inherited from Employee)
}
}This is useful when you have class level annotations that should flow down through an inheritance hierarchy without requiring every subclass to redeclare them.
@Repeatable
Normally, you cannot apply the same annotation to the same element twice. If you try, you get a compile error. But sometimes you genuinely need to categorize something in multiple ways using the same annotation.
@Repeatable is a Java 8 feature that solves this. Enabling it requires two steps.
Step one: mark the annotation you want to repeat with @Repeatable and tell it the name of a container annotation that will hold multiple instances.
Step two: create that container annotation, which holds an array of the repeatable annotation.
java
// Step 1: The repeatable annotation, pointing to its container
@Repeatable(Categories.class)
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@interface Category {
String name();
}
// Step 2: The container annotation, which holds an array
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@interface Categories {
Category[] value();
}Once you have both pieces in place, you can apply the annotation multiple times:
java
@Category(name = "bird")
@Category(name = "livingThing")
@Category(name = "carnivorous")
class Eagle {
}To read all the repeated annotations at runtime, use getAnnotationsByType() instead of getAnnotation():
java
public class Main {
public static void main(String[] args) {
Category[] categories = new Eagle().getClass().getAnnotationsByType(Category.class);
for (Category c : categories) {
System.out.println(c.name()); // bird, livingThing, carnivorous
}
}
}Internally, Java wraps the three @Category annotations inside a single @Categories annotation in the bytecode. The getAnnotationsByType() method knows how to unwrap the container and hand you back the individual annotations. This is why you cannot skip creating the container: Java needs somewhere to put the array.
Creating Your Own Custom Annotations
You define a custom annotation using @interface. Do not confuse this with the regular interface keyword. The @ prefix is what makes it an annotation definition.
The simplest possible annotation has an empty body:
java
@interface SimpleMarker {
}This is a valid annotation. You can apply it to things, but it carries no data. Frameworks often use marker annotations like this to simply signal that something should be treated a certain way.
More useful annotations carry data through members. A member looks like a method declaration with no parameters and no body. The return type is the type of data you want to store:
java
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@interface MyCustomAnnotation {
String name();
int val();
}When you apply this annotation, you must provide values for both members:
java
@MyCustomAnnotation(name = "SJ", val = 2)
class UserClass {
}The types allowed for annotation members are restricted. You can use any of the eight primitive types (byte, short, int, long, float, double, char, boolean), String, Class or Class<?>, any enum type, any other annotation type, or an array of any of the above. You cannot use arbitrary object types. You cannot have a Map<String, String> as an annotation member. This restriction exists because annotation values must be compile time constants that can be encoded directly into the bytecode.
Default Values
You can make a member optional by giving it a default value:
java
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@interface MyCustomAnnotation {
String name() default "hello";
int val() default 0;
}Now you can apply the annotation without specifying any values:
java
@MyCustomAnnotation
class AnotherClass {
// name defaults to "hello", val defaults to 0
}
@MyCustomAnnotation(name = "SJ", val = 2)
class UserClass {
// name is "SJ", val is 2
}Reading Custom Annotation Members at Runtime
Once you have RUNTIME retention on your annotation, you can read its member values through reflection:
java
MyCustomAnnotation annotation =
new UserClass().getClass().getAnnotation(MyCustomAnnotation.class);
System.out.println(annotation.name()); // SJ
System.out.println(annotation.val()); // 2You call the member names as if they were methods. The annotation system generates proxy objects at runtime that implement these method calls by returning the stored values.
A Complete End to End Example
Here is everything together in one example that shows defining, applying, and reading a custom annotation:
java
import java.lang.annotation.*;
// Define the annotation
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@interface ServiceInfo {
String serviceName();
String version() default "1.0";
boolean active() default true;
}
// Apply the annotation to a class
@ServiceInfo(serviceName = "PaymentService", version = "2.1")
class PaymentService {
public void processPayment() {
System.out.println("Processing payment");
}
}
// Read the annotation at runtime
public class Main {
public static void main(String[] args) {
Class<?> clazz = PaymentService.class;
ServiceInfo info = clazz.getAnnotation(ServiceInfo.class);
if (info != null) {
System.out.println("Service: " + info.serviceName()); // PaymentService
System.out.println("Version: " + info.version()); // 2.1
System.out.println("Active: " + info.active()); // true
}
}
}This pattern is exactly how frameworks like Spring Boot work. You put annotations on your classes and methods, and the framework reads them using reflection at startup to understand how to configure and wire everything together.
Interview Questions and Things That Trip People Up
What is the default retention policy? The default is CLASS. This means if you create an annotation without specifying @Retention, the JVM will ignore it at runtime and reflection will return null. This is the most common mistake developers make with custom annotations. Always specify @Retention(RetentionPolicy.RUNTIME) for any annotation you plan to read with reflection.
Why can @SafeVarargs only be applied to static, final, and private methods? Because these methods cannot be overridden. If a parent class has a @SafeVarargs annotation on a method and a subclass overrides that method without the annotation, all the safety guarantees disappear. Restricting it to non overridable methods ensures the guarantee cannot be silently broken.
What is heap pollution? Heap pollution occurs when a variable of a parameterized type (like List<Integer>) ends up holding a reference to an object of a different parameterized type (like List<String>). This breaks Java's type safety and usually causes a ClassCastException at runtime when you try to use the value. Varargs methods with parameterized types create this risk because Java converts the varargs to an array, and array covariance allows any array to be stored in an Object[] slot.
Can you apply the same annotation twice to the same element? Not by default. You need to mark the annotation with @Repeatable and create a container annotation that holds an array of the repeatable annotation. Then you can apply it multiple times and read all instances using getAnnotationsByType().
What happens if you put @Override on a method that is not actually overriding anything? You get a compile time error. The annotation triggers the compiler to check and verify the method signature against the parent class and all implemented interfaces. If no match is found, compilation fails.
How do you suppress a specific warning vs all warnings? Pass the specific warning name as a string to @SuppressWarnings, for example @SuppressWarnings("deprecation"). Use @SuppressWarnings("all") only when you are absolutely certain that all warnings for that element are safe to ignore, because some warnings protect against real runtime failures.
How do annotations work with inheritance? By default, a child class does not inherit annotations from its parent class. If you want the annotation to flow down through inheritance, mark it with @Inherited. This only applies to class level annotations, not method or field annotations.
What is the difference between getAnnotation() and getAnnotationsByType()? getAnnotation() returns a single annotation of the specified type, or null if not present. getAnnotationsByType() is used with repeatable annotations and returns an array of all annotations of the specified type, including any that were applied multiple times.
Putting It All Together
Annotations are one of those features in Java that feel mysterious until the moment they click. Once they click, you start seeing them everywhere: every Spring Boot application, every JPA entity mapping, every JUnit test class, every JSON serialization library. They are all just metadata with @Retention(RetentionPolicy.RUNTIME) so frameworks can read them via reflection and attach behavior.
When you want to mark your own code with information that tools or frameworks should read, define an annotation with @interface. Add @Target to restrict where it can go. Add @Retention(RetentionPolicy.RUNTIME) if you need to read it at runtime. Add members for the data you want to carry, with defaults where it makes sense. Apply it to your classes or methods. Read it with getAnnotation() at runtime.
That is the complete picture. The five predefined annotations cover the most common needs out of the box. The meta annotations give you full control over how your custom annotations behave. And the reflection API bridges the gap between the metadata you write at development time and the behavior you want at runtime.