diff --git a/oioioi/contests/admin.py b/oioioi/contests/admin.py
index f39f094c4..53a2386f3 100644
--- a/oioioi/contests/admin.py
+++ b/oioioi/contests/admin.py
@@ -200,7 +200,7 @@ class ContestLinkInline(admin.TabularInline):
 
 class ContestAdmin(admin.ModelAdmin):
     inlines = [RoundInline, AttachmentInline, ContestLinkInline]
-    readonly_fields = ['creation_date']
+    readonly_fields = ['creation_date', 'school_year']
     prepopulated_fields = {'id': ('name',)}
     list_display = ['name', 'id', 'creation_date']
     list_display_links = ['id', 'name']
@@ -218,6 +218,7 @@ def get_fields(self, request, obj=None):
         fields = [
             'name',
             'id',
+            'school_year',
             'controller_name',
             'default_submissions_limit',
             'contact_email'
diff --git a/oioioi/contests/forms.py b/oioioi/contests/forms.py
index 982c08065..c6145a5e1 100644
--- a/oioioi/contests/forms.py
+++ b/oioioi/contests/forms.py
@@ -1,6 +1,7 @@
 import json
 
 from django import forms
+from django.core.validators import RegexValidator
 from django.contrib.admin import widgets
 from django.contrib.auth.models import User
 from django.forms import ValidationError
@@ -31,7 +32,7 @@ class Meta(object):
         # form should not be on the 'name' field, otherwise the 'id' field,
         # as prepopulated with 'name' in ContestAdmin model, is cleared by
         # javascript with prepopulated fields functionality.
-        fields = ['controller_name', 'name', 'id']
+        fields = ['controller_name', 'name', 'id', 'school_year']
 
     start_date = forms.SplitDateTimeField(
         label=_("Start date"), widget=widgets.AdminSplitDateTime()
@@ -43,6 +44,23 @@ class Meta(object):
         required=False, label=_("Results date"), widget=widgets.AdminSplitDateTime()
     )
 
+    def validate_years(year):
+        year1 = int(year[:4])
+        year2 = int(year[5:])
+        if year1+1 != year2:
+            raise ValidationError("The selected years must be consecutive.")
+
+    school_year = forms.CharField(
+        required=False, label=_("School year"), validators=[        
+            RegexValidator(
+                regex=r'^[0-9]{4}[/][0-9]{4}$',
+                message="Enter a valid school year in the format 2021/2022.",
+                code="invalid_school_year",
+            ),
+            validate_years,
+            ]
+    )
+
     def _generate_default_dates(self):
         now = timezone.now()
         self.initial['start_date'] = now
diff --git a/oioioi/contests/migrations/0020_contest_school_year.py b/oioioi/contests/migrations/0020_contest_school_year.py
new file mode 100644
index 000000000..c97b06915
--- /dev/null
+++ b/oioioi/contests/migrations/0020_contest_school_year.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.2.19 on 2025-04-09 14:28
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('contests', '0019_submissionmessage'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='contest',
+            name='school_year',
+            field=models.CharField(default='', max_length=10, verbose_name='school year'),
+        ),
+    ]
diff --git a/oioioi/contests/models.py b/oioioi/contests/models.py
index 7f7a85f17..e0837c298 100644
--- a/oioioi/contests/models.py
+++ b/oioioi/contests/models.py
@@ -108,6 +108,9 @@ class Contest(models.Model):
         verbose_name=_("is archived"),
         default=False
     )
+    school_year = models.CharField(
+        max_length=10, verbose_name=_("school year"),  default=""
+    )
 
     # Part of szkopul backporting.
     # This is a hack for situation where contest controller is empty,
diff --git a/oioioi/contests/templates/contests/select_contest.html b/oioioi/contests/templates/contests/select_contest.html
index 02c0bc03e..8baf7bd4b 100644
--- a/oioioi/contests/templates/contests/select_contest.html
+++ b/oioioi/contests/templates/contests/select_contest.html
@@ -8,7 +8,30 @@
 <article>
     <h1>{% trans "Select contest" %}</h1>
 
-    <div class="table-responsive-md">
+    <div style="width: 20%; margin-bottom: 1rem;">
+            <form method="GET" action="{% url 'filter_contests' filter_value='PLACEHOLDER' %}" id="filter_form">
+                <div class="input-group">
+                <input type="text" id="filter_input" class="form-control search-query" style="width: 20%;" placeholder="Search" name="filter_field" value="{{ filter }}">
+                <span class="input-group-btn">
+                    <button type="submit" class="btn btn-outline-secondary "  name="submit_button"> <i class="fa-solid fa-magnifying-glass"> </i> </button>
+                </span>
+                </div>  
+            </form>
+            <script>
+                document.getElementById('filter_form').addEventListener('submit', function(event) {
+                    event.preventDefault();
+                    let filterValue = document.getElementById('filter_input').value;
+                    let actionUrl = "";
+                    if(filterValue == "") {
+                        actionUrl = "{% url 'select_contest'%}";
+                    } else {
+                        actionUrl = "{% url 'filter_contests' filter_value='PLACEHOLDER' %}".replace('PLACEHOLDER', encodeURIComponent(filterValue));
+                    }
+                    window.location.href = actionUrl;
+                });
+            </script>
+        </div>
+        <div class="table-responsive-md">
         <table class="table">
             <thead>
                 <tr>
diff --git a/oioioi/contests/tests/tests.py b/oioioi/contests/tests/tests.py
index a6c4fd587..9f6f950bb 100755
--- a/oioioi/contests/tests/tests.py
+++ b/oioioi/contests/tests/tests.py
@@ -4499,3 +4499,30 @@ def test_score_badge(self):
         self.assertIn('badge-success', self._get_badge_for_problem(response.content, 'zad1'))
         self.assertIn('badge-warning', self._get_badge_for_problem(response.content, 'zad2'))
         self.assertIn('badge-danger', self._get_badge_for_problem(response.content, 'zad3'))
+
+class TestContestListFiltering(TestCase):
+    fixtures = [
+        'test_contest',
+        'test_extra_contests',
+    ]
+
+    def setUp(self):
+        self.c = Contest.objects.get(id='c')
+        self.c1 = Contest.objects.get(id='c1')   
+        self.c2 = Contest.objects.get(id='c2')
+
+        return super().setUp()
+
+    def test_simple_filter(self):
+        self.url = reverse('filter_contests', kwargs={'filter_value':'test'})
+        response = self.client.get(self.url, follow=True)
+        self.assertContains(response, self.c.name)
+        self.assertContains(response, self.c1.name)
+        self.assertContains(response, self.c2.name)
+
+    def extra_filter(self):
+        self.url = reverse('filter_contests', kwargs={'filter_value':'ExTrA'})
+        response = self.client.get(self.url, follow=True)
+        self.assertNotContains(response, self.c.name)
+        self.assertContains(response, self.c1.name)
+        self.assertContains(response, self.c2.name)
diff --git a/oioioi/contests/urls.py b/oioioi/contests/urls.py
index 8dad6c635..173145c86 100644
--- a/oioioi/contests/urls.py
+++ b/oioioi/contests/urls.py
@@ -193,6 +193,11 @@ def glob_namespaced_patterns(namespace):
         views.reattach_problem_confirm_view,
         name='reattach_problem_confirm',
     ),
+    re_path(
+        r'^contest/query/(?P<filter_value>.+)/$', 
+        views.filter_contests_view, 
+        name='filter_contests',
+    ),
 ]
 
 if settings.USE_API:
diff --git a/oioioi/contests/utils.py b/oioioi/contests/utils.py
index 05afdffd9..0447a239c 100755
--- a/oioioi/contests/utils.py
+++ b/oioioi/contests/utils.py
@@ -397,10 +397,8 @@ def used_controllers():
     by contests on this instance.
     """
     return Contest.objects.values_list('controller_name', flat=True).distinct()
-
-
-@request_cached
-def visible_contests(request):
+    
+def visible_contests_query(request):
     """Returns materialized set of contests visible to the logged in user."""
     if request.GET.get('living', 'safely') == 'dangerously':
         visible_query = Contest.objects.none()
@@ -423,8 +421,18 @@ def visible_contests(request):
         visible_query |= Q(
             controller_name=controller_name
         ) & controller.registration_controller().visible_contests_query(request)
-    return set(Contest.objects.filter(visible_query).distinct())
+    return Contest.objects.filter(visible_query).distinct()
 
+@request_cached
+def visible_contests(request):
+    contests = visible_contests_query(request)
+    return set(contests)
+
+@request_cached_complex
+def visible_contests_queryset(request, filter_value):
+    contests = visible_contests_query(request)
+    contests = contests.filter(Q(name__icontains=filter_value) | Q(id__icontains=filter_value) | Q(school_year=filter_value))    
+    return set(contests)
 
 @request_cached
 def administered_contests(request):
diff --git a/oioioi/contests/views.py b/oioioi/contests/views.py
index 69f5ba0bb..56a829b97 100755
--- a/oioioi/contests/views.py
+++ b/oioioi/contests/views.py
@@ -52,6 +52,7 @@
     is_contest_basicadmin,
     is_contest_observer,
     visible_contests,
+    visible_contests_queryset, 
     visible_problem_instances,
     visible_rounds,
     get_files_message,
@@ -838,3 +839,14 @@ def unarchive_contest(request):
     contest.is_archived = False
     contest.save()
     return redirect('default_contest_view', contest_id=contest.id)
+
+def filter_contests_view(request, filter_value=""):
+    contests = visible_contests_queryset(request, filter_value)
+    contests = sorted(contests, key=lambda x: x.creation_date, reverse=True)
+    
+    context = {
+        'contests' : contests,
+    }  
+    return TemplateResponse(
+        request, 'contests/select_contest.html', context
+    )
\ No newline at end of file