Repository: vacanza/holidays
Pull request #2328 (2025-03-05 10:53:33)

Update Indonesia holidays: 2031-2050 Day of Silence (Nyepi) data, migrate to `BalineseSakaCalendarHolidays`
<!--
  Thanks for contributing to holidays!
-->

## Proposed change

<!--
  Describe the big picture of your changes.
  Don't forget to link your PR to an existing issue if any.
-->

- Adds 2031-2050 Day of Silence (Nyepi) holidays data.
- Migrate existing Nyepi holiday data to the new standalone `_BalineseSakaLunar` calendar and `BalineseSakaCalendarHolidays` holiday group to make it easier to extend support beyond 2050.

## Type of change

<!--
  Type of change you want to introduce. Please, check one (1) box only!
  If your PR requires multiple boxes to be checked, most likely it needs to
  be split into multiple PRs.
-->

- [ ] New country/market holidays support (thank you!)
- [x] Supported country/market holidays update (calendar discrepancy fix, localization)
- [ ] Existing code/documentation/test/process quality improvement (best practice, cleanup, refactoring, optimization)
- [ ] Dependency update (version deprecation/pin/upgrade)
- [ ] Bugfix (non-breaking change which fixes an issue)
- [ ] Breaking change (a code change causing existing functionality to break)
- [ ] New feature (new `holidays` functionality in general)

## Checklist

<!--
  Put an `x` in the boxes that apply. You can change them after PR is created.
-->

- [x] I've followed the [contributing guidelines][contributing-guidelines]
- [x] I've successfully run `make check`, all checks and tests are green

<!--
  Thanks again for your contribution!
-->

[contributing-guidelines]: https://github.com/vacanza/holidays/blob/dev/CONTRIBUTING.rst
[docs]: https://github.com/vacanza/holidays/tree/dev/docs/source


coderabbitai[bot]: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- This is an auto-generated comment: review in progress by coderabbit.ai -->

> [!NOTE]
> Currently processing new changes in this PR. This may take a few minutes, please wait...
> 
> <details>
> <summary>📥 Commits</summary>
> 
> Reviewing files that changed from the base of the PR and between 3f52858ffc7647ff57b14daa0859a6ee9146ea58 and fd96370ad235c7b925ec3937ef79ae63f4b1235d.
> 
> </details>
> 
> <details>
> <summary>📒 Files selected for processing (3)</summary>
> 
> * `holidays/countries/indonesia.py` (2 hunks)
> * `snapshots/countries/ID_COMMON.json` (20 hunks)
> * `tests/countries/test_indonesia.py` (1 hunks)
> 
> </details>
> 
> ```ascii
>  _____________________________________________________________________
> < No matter how far down the wrong road you have gone, turn back now. >
>  ---------------------------------------------------------------------
>   \
>    \   (\__/)
>        (•ㅅ•)
>        / 　 づ
> ```

<!-- end of auto-generated comment: review in progress by coderabbit.ai -->
<!-- tips_start -->

---



<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=vacanza/holidays&utm_content=2328):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Generate unit testing code for this file.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai generate unit testing code for this file.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and generate unit testing code.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->
sonarqubecloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=vacanza_python-holidays&pullRequest=2328) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=vacanza_python-holidays&pullRequest=2328&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=vacanza_python-holidays&pullRequest=2328&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=vacanza_python-holidays&pullRequest=2328&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=vacanza_python-holidays&pullRequest=2328&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=vacanza_python-holidays&pullRequest=2328&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=vacanza_python-holidays&pullRequest=2328)


PPsyrius: @arkid15r I've noticed that `codecov` hasn't been running unless it reaches 100.00% coverage since #2173 was implemented, defeating its very purpose of identifying missing coverage cases.

Given that our existing `sonarqubecloud` tool also supports test coverage display, perhaps we should consider [implementing it](https://docs.sonarsource.com/sonarqube-cloud/enriching/test-coverage/python-test-coverage/) at some point to streamline our workflow. 🙏 
KJhellico: I use `make coverage` in such cases.
sonarqubecloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=vacanza_python-holidays&pullRequest=2328) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=vacanza_python-holidays&pullRequest=2328&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=vacanza_python-holidays&pullRequest=2328&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=vacanza_python-holidays&pullRequest=2328&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=vacanza_python-holidays&pullRequest=2328&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=vacanza_python-holidays&pullRequest=2328&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=vacanza_python-holidays&pullRequest=2328)


sonarqubecloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=vacanza_python-holidays&pullRequest=2328) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=vacanza_python-holidays&pullRequest=2328&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=vacanza_python-holidays&pullRequest=2328&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=vacanza_python-holidays&pullRequest=2328&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=vacanza_python-holidays&pullRequest=2328&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=vacanza_python-holidays&pullRequest=2328&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=vacanza_python-holidays&pullRequest=2328)


sonarqubecloud[bot]: Please retry analysis of this Pull-Request directly on SonarQube Cloud
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/vacanza/holidays/pull/2328?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=vacanza) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 100.00%. Comparing base [(`93bb4af`)](https://app.codecov.io/gh/vacanza/holidays/commit/93bb4af0c3d0f4e743756d56a70d625ced4e3071?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=vacanza) to head [(`6a43dfb`)](https://app.codecov.io/gh/vacanza/holidays/commit/6a43dfb5e1a191340d9639548e0a6b87be1c66e8?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=vacanza).

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff            @@
##               dev     #2328   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files          201       203    +2     
  Lines        12378     12407   +29     
  Branches      1777      1776    -1     
=========================================
+ Hits         12378     12407   +29     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/vacanza/holidays/pull/2328?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=vacanza).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=vacanza).

<details><summary>🚀 New features to boost your workflow: </summary>

- ❄ [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
sonarqubecloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=vacanza_python-holidays&pullRequest=2328) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=vacanza_python-holidays&pullRequest=2328&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=vacanza_python-holidays&pullRequest=2328&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=vacanza_python-holidays&pullRequest=2328&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=vacanza_python-holidays&pullRequest=2328&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=vacanza_python-holidays&pullRequest=2328&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=vacanza_python-holidays&pullRequest=2328)


arkid15r: > @arkid15r I've noticed that `codecov` hasn't been running unless it reaches 100.00% coverage since #2173 was implemented, defeating its very purpose of identifying missing coverage cases.
> 
> Given that our existing `sonarqubecloud` tool also supports test coverage display, perhaps we should consider [implementing it](https://docs.sonarsource.com/sonarqube-cloud/enriching/test-coverage/python-test-coverage/) at some point to streamline our workflow. 🙏

I'll look into improving this, thanks for noticing!

Patch:
diff --git a/holidays/calendars/__init__.py b/holidays/calendars/__init__.py
index 7edbcd8c..6cb93a30 100644
--- a/holidays/calendars/__init__.py
+++ b/holidays/calendars/__init__.py
@@ -12,6 +12,7 @@
 
 # flake8: noqa: F401
 
+from holidays.calendars.balinese_saka import _BalineseSakaLunar
 from holidays.calendars.buddhist import _BuddhistLunisolar, _CustomBuddhistHolidays
 from holidays.calendars.chinese import _ChineseLunisolar, _CustomChineseHolidays
 from holidays.calendars.custom import _CustomCalendar
diff --git a/holidays/calendars/balinese_saka.py b/holidays/calendars/balinese_saka.py
new file mode 100644
index 00000000..f2b8dd05
--- /dev/null
+++ b/holidays/calendars/balinese_saka.py
@@ -0,0 +1,112 @@
+#  holidays
+#  --------
+#  A fast, efficient Python library for generating country, province and state
+#  specific sets of holidays on the fly. It aims to make determining whether a
+#  specific date is a holiday as fast and flexible as possible.
+#
+#  Authors: Vacanza Team and individual contributors (see AUTHORS file)
+#           dr-prodigy <dr.prodigy.github@gmail.com> (c) 2017-2023
+#           ryanss <ryanssdev@icloud.com> (c) 2014-2017
+#  Website: https://github.com/vacanza/holidays
+#  License: MIT (see LICENSE file)
+
+from datetime import date
+from typing import Optional
+
+from holidays.calendars.gregorian import MAR, APR
+
+NYEPI = "NYEPI"
+
+
+class _BalineseSakaLunar:
+    """
+    Balinese Saka lunar calendar.
+
+    The Balinese saka calendar is one of two calendars used on the Indonesian island
+    of Bali. Unlike the 210-day pawukon calendar, it is based on the phases of the Moon,
+    and is approximately the same length as the tropical year (solar year, Gregorian year).
+    https://en.wikipedia.org/wiki/Balinese_saka_calendar
+    """
+
+    NYEPI_DATES = {
+        1983: (MAR, 15),
+        1984: (MAR, 4),
+        1985: (MAR, 22),
+        1986: (MAR, 12),
+        1987: (MAR, 31),
+        1988: (MAR, 19),
+        1989: (MAR, 9),
+        1990: (MAR, 27),
+        1991: (MAR, 17),
+        1992: (MAR, 5),
+        1993: (MAR, 24),
+        1994: (MAR, 12),
+        1995: (APR, 1),
+        1996: (MAR, 21),
+        1997: (APR, 9),
+        1998: (MAR, 29),
+        1999: (MAR, 18),
+        2000: (APR, 4),
+        2001: (MAR, 25),
+        2002: (APR, 13),
+        2003: (APR, 2),
+        2004: (MAR, 22),
+        2005: (MAR, 11),
+        2006: (MAR, 30),
+        2007: (MAR, 19),
+        2008: (MAR, 7),
+        2009: (MAR, 26),
+        2010: (MAR, 16),
+        2011: (MAR, 5),
+        2012: (MAR, 23),
+        2013: (MAR, 12),
+        2014: (MAR, 31),
+        2015: (MAR, 21),
+        2016: (MAR, 9),
+        2017: (MAR, 28),
+        2018: (MAR, 17),
+        2019: (MAR, 7),
+        2020: (MAR, 25),
+        2021: (MAR, 14),
+        2022: (MAR, 3),
+        2023: (MAR, 22),
+        2024: (MAR, 11),
+        2025: (MAR, 29),
+        2026: (MAR, 19),
+        2027: (MAR, 8),
+        2028: (MAR, 26),
+        2029: (MAR, 15),
+        2030: (MAR, 5),
+        2031: (MAR, 24),
+        2032: (MAR, 12),
+        2033: (MAR, 31),
+        2034: (MAR, 20),
+        2035: (MAR, 10),
+        2036: (MAR, 28),
+        2037: (MAR, 17),
+        2038: (MAR, 6),
+        2039: (MAR, 25),
+        2040: (MAR, 14),
+        2041: (MAR, 3),
+        2042: (MAR, 22),
+        2043: (MAR, 11),
+        2044: (MAR, 29),
+        2045: (MAR, 19),
+        2046: (MAR, 8),
+        2047: (MAR, 27),
+        2048: (MAR, 15),
+        2049: (MAR, 5),
+        2050: (MAR, 24),
+    }
+
+    def _get_holiday(self, holiday: str, year: int) -> Optional[date]:
+        dt = getattr(self, f"{holiday}_DATES", {}).get(year, ())
+        return date(year, *dt) if dt else None
+
+    def nyepi_date(self, year: int) -> Optional[date]:
+        """
+        Data References:
+        - `1983-2025 <https://id.wikipedia.org/wiki/Indonesia_dalam_tahun_1983>`_
+        - `2020-2050 <https://www.balitrips.com/balinese-temples-ceremony>`_
+        """
+        return self._get_holiday(NYEPI, year)
diff --git a/holidays/countries/indonesia.py b/holidays/countries/indonesia.py
index 8c7e3ff2..7c787bb8 100644
--- a/holidays/countries/indonesia.py
+++ b/holidays/countries/indonesia.py
@@ -20,6 +20,7 @@ from holidays.calendars import (
 from holidays.calendars.gregorian import JAN, FEB, MAR, APR, MAY, JUN, JUL, AUG, SEP, OCT, NOV, DEC
 from holidays.constants import GOVERNMENT, PUBLIC
 from holidays.groups import (
+    BalineseSakaCalendarHolidays,
     BuddhistCalendarHolidays,
     ChineseCalendarHolidays,
     ChristianHolidays,
@@ -32,6 +33,7 @@ from holidays.observed_holiday_base import ObservedHolidayBase
 
 class Indonesia(
     ObservedHolidayBase,
+    BalineseSakaCalendarHolidays,
     BuddhistCalendarHolidays,
     ChineseCalendarHolidays,
     ChristianHolidays,
@@ -44,10 +46,8 @@ class Indonesia(
     - https://en.wikipedia.org/wiki/Public_holidays_in_Indonesia
     - https://id.wikipedia.org/wiki/Hari_libur_di_Indonesia
     - https://www.liburnasional.com/sejarah/
-    - https://id.wikipedia.org/wiki/Indonesia_dalam_tahun_1963 (1963-2024)
+    - https://id.wikipedia.org/wiki/Indonesia_dalam_tahun_1963 (1963-2025)
     - https://www.timeanddate.com/holidays/indonesia
-    - https://en.wikipedia.org/wiki/Nyepi
-    - https://bali.com/bali/travel-guide/culture/nyepi-balinese-new-year/
     """
 
     country = "ID"
@@ -63,6 +63,7 @@ class Indonesia(
     start_year = 1946
 
     def __init__(self, *args, **kwargs):
+        BalineseSakaCalendarHolidays.__init__(self)
         BuddhistCalendarHolidays.__init__(self, cls=IndonesiaBuddhistHolidays, show_estimated=True)
         ChineseCalendarHolidays.__init__(self, cls=IndonesiaChineseHolidays, show_estimated=True)
         ChristianHolidays.__init__(self)
@@ -148,59 +149,8 @@ class Indonesia(
 
         # Keputusan Presiden no 3 tahun 1983. (1983-01-19; Added Nationally)
         if self._year >= 1983:
-            dates_obs = {
-                1983: (MAR, 15),
-                1984: (MAR, 4),
-                1985: (MAR, 22),
-                1986: (MAR, 12),
-                1987: (MAR, 31),
-                1988: (MAR, 19),
-                1989: (MAR, 9),
-                1990: (MAR, 27),
-                1991: (MAR, 17),
-                1992: (MAR, 5),
-                1993: (MAR, 24),
-                1994: (MAR, 12),
-                1995: (APR, 1),
-                1996: (MAR, 21),
-                1997: (APR, 9),
-                1998: (MAR, 29),
-                1999: (MAR, 18),
-                2000: (APR, 4),
-                2001: (MAR, 25),
-                2002: (APR, 13),
-                2003: (APR, 2),
-                2004: (MAR, 22),
-                2005: (MAR, 11),
-                2006: (MAR, 30),
-                2007: (MAR, 19),
-                2008: (MAR, 7),
-                2009: (MAR, 26),
-                2010: (MAR, 16),
-                2011: (MAR, 5),
-                2012: (MAR, 23),
-                2013: (MAR, 12),
-                2014: (MAR, 31),
-                2015: (MAR, 21),
-                2016: (MAR, 9),
-                2017: (MAR, 28),
-                2018: (MAR, 17),
-                2019: (MAR, 7),
-                2020: (MAR, 25),
-                2021: (MAR, 14),
-                2022: (MAR, 3),
-                2023: (MAR, 22),
-                2024: (MAR, 11),
-                2025: (MAR, 29),
-                2026: (MAR, 19),
-                2027: (MAR, 8),
-                2028: (MAR, 26),
-                2029: (MAR, 15),
-                2030: (MAR, 5),
-            }
-            if self._year in dates_obs:
-                # Day of Silence.
-                self._add_holiday(tr("Hari Suci Nyepi"), dates_obs[self._year])
+            # Day of Silence.
+            self._add_nyepi(tr("Hari Suci Nyepi"))
 
             # Vesak Day.
             self._add_vesak(tr("Hari Raya Waisak"))
diff --git a/holidays/groups/__init__.py b/holidays/groups/__init__.py
index 1e940a1c..e134db80 100644
--- a/holidays/groups/__init__.py
+++ b/holidays/groups/__init__.py
@@ -12,6 +12,7 @@
 
 # flake8: noqa: F401
 
+from holidays.groups.balinese_saka import BalineseSakaCalendarHolidays
 from holidays.groups.buddhist import BuddhistCalendarHolidays
 from holidays.groups.chinese import ChineseCalendarHolidays
 from holidays.groups.christian import ChristianHolidays
diff --git a/holidays/groups/balinese_saka.py b/holidays/groups/balinese_saka.py
new file mode 100644
index 00000000..3f519af0
--- /dev/null
+++ b/holidays/groups/balinese_saka.py
@@ -0,0 +1,45 @@
+#  holidays
+#  --------
+#  A fast, efficient Python library for generating country, province and state
+#  specific sets of holidays on the fly. It aims to make determining whether a
+#  specific date is a holiday as fast and flexible as possible.
+#
+#  Authors: Vacanza Team and individual contributors (see AUTHORS file)
+#           dr-prodigy <dr.prodigy.github@gmail.com> (c) 2017-2023
+#           ryanss <ryanssdev@icloud.com> (c) 2014-2017
+#  Website: https://github.com/vacanza/holidays
+#  License: MIT (see LICENSE file)
+
+from datetime import date
+from typing import Optional
+
+from holidays.calendars import _BalineseSakaLunar
+
+
+class BalineseSakaCalendarHolidays:
+    """
+    Balinese Saka lunar calendar holidays.
+    """
+
+    def __init__(self) -> None:
+        self._balinese_saka_calendar = _BalineseSakaLunar()
+
+    def _add_balinese_saka_calendar_holiday(self, name: str, dt: Optional[date]) -> Optional[date]:
+        """
+        Add Balinese Saka calendar holiday.
+        """
+        if dt is None:
+            return None
+        return self._add_holiday(name, dt)
+
+    def _add_nyepi(self, name) -> Optional[date]:
+        """
+        Add Nyepi (Day following the 9th of Dark Moon (Tilem)).
+
+        Nyepi is a Balinese "Day of Silence" that is commemorated every
+        Isakawarsa (Saka new year) according to the Balinese calendar.
+        https://en.wikipedia.org/wiki/Nyepi
+        """
+        return self._add_balinese_saka_calendar_holiday(
+            name, self._balinese_saka_calendar.nyepi_date(self._year)
+        )
diff --git a/snapshots/countries/ID_COMMON.json b/snapshots/countries/ID_COMMON.json
index e391c547..b26be8f2 100644
--- a/snapshots/countries/ID_COMMON.json
+++ b/snapshots/countries/ID_COMMON.json
@@ -1217,6 +1217,7 @@
     "2031-01-23": "Lunar New Year (estimated)",
     "2031-01-24": "Eid al-Fitr (estimated)",
     "2031-01-25": "Eid al-Fitr Second Day (estimated)",
+    "2031-03-24": "Day of Silence",
     "2031-04-02": "Eid al-Adha (estimated)",
     "2031-04-11": "Good Friday",
     "2031-04-13": "Easter Sunday",
@@ -1233,6 +1234,7 @@
     "2032-01-14": "Eid al-Fitr (estimated)",
     "2032-01-15": "Eid al-Fitr Second Day (estimated)",
     "2032-02-11": "Lunar New Year (estimated)",
+    "2032-03-12": "Day of Silence",
     "2032-03-22": "Eid al-Adha (estimated)",
     "2032-03-26": "Good Friday",
     "2032-03-28": "Easter Sunday",
@@ -1250,6 +1252,7 @@
     "2033-01-03": "Eid al-Fitr Second Day (estimated)",
     "2033-01-31": "Lunar New Year (estimated)",
     "2033-03-11": "Eid al-Adha (estimated)",
+    "2033-03-31": "Day of Silence",
     "2033-04-01": "Islamic New Year (estimated)",
     "2033-04-15": "Good Friday",
     "2033-04-17": "Easter Sunday",
@@ -1266,6 +1269,7 @@
     "2034-01-01": "New Year's Day",
     "2034-02-19": "Lunar New Year (estimated)",
     "2034-03-01": "Eid al-Adha (estimated)",
+    "2034-03-20": "Day of Silence",
     "2034-03-21": "Islamic New Year (estimated)",
     "2034-04-07": "Good Friday",
     "2034-04-09": "Easter Sunday",
@@ -1281,6 +1285,7 @@
     "2035-01-01": "New Year's Day",
     "2035-02-08": "Lunar New Year (estimated)",
     "2035-02-18": "Eid al-Adha (estimated)",
+    "2035-03-10": "Day of Silence",
     "2035-03-11": "Islamic New Year (estimated)",
     "2035-03-23": "Good Friday",
     "2035-03-25": "Easter Sunday",
@@ -1298,6 +1303,7 @@
     "2036-01-28": "Lunar New Year (estimated)",
     "2036-02-07": "Eid al-Adha (estimated)",
     "2036-02-28": "Islamic New Year (estimated)",
+    "2036-03-28": "Day of Silence",
     "2036-04-11": "Good Friday",
     "2036-04-13": "Easter Sunday",
     "2036-05-01": "International Labor Day",
@@ -1314,6 +1320,7 @@
     "2037-01-26": "Eid al-Adha (estimated)",
     "2037-02-15": "Lunar New Year (estimated)",
     "2037-02-16": "Islamic New Year (estimated)",
+    "2037-03-17": "Day of Silence",
     "2037-04-03": "Good Friday",
     "2037-04-05": "Easter Sunday",
     "2037-04-28": "Prophet's Birthday (estimated)",
@@ -1330,6 +1337,7 @@
     "2038-01-16": "Eid al-Adha (estimated)",
     "2038-02-04": "Lunar New Year (estimated)",
     "2038-02-05": "Islamic New Year (estimated)",
+    "2038-03-06": "Day of Silence",
     "2038-04-17": "Prophet's Birthday (estimated)",
     "2038-04-23": "Good Friday",
     "2038-04-25": "Easter Sunday",
@@ -1346,6 +1354,7 @@
     "2039-01-05": "Eid al-Adha (estimated)",
     "2039-01-24": "Lunar New Year (estimated)",
     "2039-01-26": "Islamic New Year (estimated)",
+    "2039-03-25": "Day of Silence",
     "2039-04-06": "Prophet's Birthday (estimated)",
     "2039-04-08": "Good Friday",
     "2039-04-10": "Easter Sunday",
@@ -1361,6 +1370,7 @@
     "2040-01-01": "New Year's Day",
     "2040-01-15": "Islamic New Year (estimated)",
     "2040-02-12": "Lunar New Year (estimated)",
+    "2040-03-14": "Day of Silence",
     "2040-03-25": "Prophet's Birthday (estimated)",
     "2040-03-30": "Good Friday",
     "2040-04-01": "Easter Sunday",
@@ -1377,6 +1387,7 @@
     "2041-01-01": "New Year's Day",
     "2041-01-04": "Islamic New Year (estimated)",
     "2041-02-01": "Lunar New Year (estimated)",
+    "2041-03-03": "Day of Silence",
     "2041-03-15": "Prophet's Birthday (estimated)",
     "2041-04-19": "Good Friday",
     "2041-04-21": "Easter Sunday",
@@ -1394,6 +1405,7 @@
     "2042-01-01": "New Year's Day",
     "2042-01-22": "Lunar New Year (estimated)",
     "2042-03-04": "Prophet's Birthday (estimated)",
+    "2042-03-22": "Day of Silence",
     "2042-04-04": "Good Friday",
     "2042-04-06": "Easter Sunday",
     "2042-05-01": "International Labor Day",
@@ -1410,6 +1422,7 @@
     "2043-01-01": "New Year's Day",
     "2043-02-10": "Lunar New Year (estimated)",
     "2043-02-22": "Prophet's Birthday (estimated)",
+    "2043-03-11": "Day of Silence",
     "2043-03-27": "Good Friday",
     "2043-03-29": "Easter Sunday",
     "2043-05-01": "International Labor Day",
@@ -1426,6 +1439,7 @@
     "2044-01-01": "New Year's Day",
     "2044-01-30": "Lunar New Year (estimated)",
     "2044-02-11": "Prophet's Birthday (estimated)",
+    "2044-03-29": "Day of Silence",
     "2044-04-15": "Good Friday",
     "2044-04-17": "Easter Sunday",
     "2044-05-01": "International Labor Day",
@@ -1442,6 +1456,7 @@
     "2045-01-01": "New Year's Day",
     "2045-01-30": "Prophet's Birthday (estimated)",
     "2045-02-17": "Lunar New Year (estimated)",
+    "2045-03-19": "Day of Silence",
     "2045-04-07": "Good Friday",
     "2045-04-09": "Easter Sunday",
     "2045-05-01": "International Labor Day",
@@ -1458,6 +1473,7 @@
     "2046-01-01": "New Year's Day",
     "2046-01-19": "Prophet's Birthday (estimated)",
     "2046-02-06": "Lunar New Year (estimated)",
+    "2046-03-08": "Day of Silence",
     "2046-03-23": "Good Friday",
     "2046-03-25": "Easter Sunday",
     "2046-05-01": "International Labor Day",
@@ -1474,6 +1490,7 @@
     "2047-01-01": "New Year's Day",
     "2047-01-08": "Prophet's Birthday (estimated)",
     "2047-01-26": "Lunar New Year (estimated)",
+    "2047-03-27": "Day of Silence",
     "2047-04-12": "Good Friday",
     "2047-04-14": "Easter Sunday",
     "2047-05-01": "International Labor Day",
@@ -1490,6 +1507,7 @@
     "2047-12-29": "Prophet's Birthday (estimated)",
     "2048-01-01": "New Year's Day",
     "2048-02-14": "Lunar New Year (estimated)",
+    "2048-03-15": "Day of Silence",
     "2048-04-03": "Good Friday",
     "2048-04-05": "Easter Sunday",
     "2048-05-01": "International Labor Day",
@@ -1506,6 +1524,7 @@
     "2048-12-25": "Christmas Day",
     "2049-01-01": "New Year's Day",
     "2049-02-02": "Lunar New Year (estimated)",
+    "2049-03-05": "Day of Silence",
     "2049-04-16": "Good Friday",
     "2049-04-18": "Easter Sunday",
     "2049-04-29": "Isra' and Mi'raj (estimated)",
@@ -1522,6 +1541,7 @@
     "2049-12-25": "Christmas Day",
     "2050-01-01": "New Year's Day",
     "2050-01-23": "Lunar New Year (estimated)",
+    "2050-03-24": "Day of Silence",
     "2050-04-08": "Good Friday",
     "2050-04-10": "Easter Sunday",
     "2050-04-19": "Isra' and Mi'raj (estimated)",


Test patch:
diff --git a/tests/countries/test_indonesia.py b/tests/countries/test_indonesia.py
index 295354c7..7943245a 100644
--- a/tests/countries/test_indonesia.py
+++ b/tests/countries/test_indonesia.py
@@ -234,6 +234,7 @@ class TestIndonesia(CommonCountryTests, TestCase):
             "2021-03-14",
             "2022-03-03",
         )
+        self.assertHolidayName(name, range(1983, 2050))
         self.assertNoHolidayName(name, range(1946, 1983))
 
     def test_good_friday(self):
diff --git a/tests/test_holiday_groups.py b/tests/test_holiday_groups.py
index d30e27b7..50e3ca22 100644
--- a/tests/test_holiday_groups.py
+++ b/tests/test_holiday_groups.py
@@ -14,6 +14,7 @@ from unittest import TestCase
 
 from holidays.calendars.thai import KHMER_CALENDAR
 from holidays.groups import (
+    BalineseSakaCalendarHolidays,
     ChristianHolidays,
     InternationalHolidays,
     PersianCalendarHolidays,
@@ -22,6 +23,23 @@ from holidays.groups import (
 from holidays.holiday_base import HolidayBase
 
 
+class TestBalineseSakaCalendarHolidays(TestCase):
+    def test_add_balinese_saka_calendar_holiday(self):
+        # Check for out-of-range dates.
+        class TestHolidays(HolidayBase, BalineseSakaCalendarHolidays):
+            end_year = 2051
+
+            def __init__(self, *args, **kwargs):
+                BalineseSakaCalendarHolidays.__init__(self)
+                super().__init__(*args, **kwargs)
+
+        test_holidays = TestHolidays()
+
+        test_holidays._populate(2051)
+        test_holidays._add_nyepi("Day of Silence")
+        self.assertEqual(0, len(test_holidays))
+
+
 class TestChristianHolidays(TestCase):
     def test_check_calendar(self):
         self.assertRaises(ValueError, lambda: ChristianHolidays("INVALID_CALENDAR"))