You can use matplotlib annotations to draw each of the arrows as well as add corresponding text. Your problem is less straightforward because the annotations are conditional, depending on whether expected < actual or expected > actual.
To access the heights and widths of the bars so that you can tell matplotlib where to place the annotations, you can create an object to store the pandas barplot using plots = df.plot(kind='bar', ax=ax) and iterate over plots.patches, as described in this article.
However, since you have a grouped bar plot with left and right bars, iterating over the list plot.patches will return the left bar objects, followed by the right bar objects, so it's best that we iterate over the first half of the plot.patches list and the last half of the plot.patches list simultaneously, choosing whether to annotate the left or right bar, which I have done by applying zip() to the first half of plot.patches and the second half of plot.patches.
The rest is determining the starting and ending x-coordinates and y-coordinates of each arrow annotation depending on whether expected < actual or expected > actual, as well as the color of your arrow, and the location of the text relative to the bars to get as close as possible to your desired plot.
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
df = pd.DataFrame({"Month":['Jan','Feb','Mar','Apr'], 'Expected':[30, 25, 50, 20],'Actual':[40, 23, 51, 17]})
rate_off = np.round(100-np.abs(df['Expected']/df['Actual']*100),2)
fig, ax = plt.subplots(1,1)
## store the plot in an object
plots = df.plot(kind='bar', ax=ax)
## iterating over plots.patches will return the objects for left bars, then the objects for the right bars
## so it is best to iterate over the left and right bar objects simultaneously
middle_index = len(plots.patches)//2
for change, left_bar, right_bar in zip(rate_off, plots.patches[:middle_index], plots.patches[middle_index:]):
## for expected less than actual, the annotation starts from the left bar
if change > 0:
# print(change, left_bar.get_x())
plots.annotate(f"",
xy=(left_bar.get_x() + left_bar.get_width() / 2, right_bar.get_height()),
ha='center', va='center', size=10,
xytext=(left_bar.get_x() + left_bar.get_width() / 2, left_bar.get_height()),
arrowprops=dict(arrowstyle="simple", color='green', facecolor='green'))
## annotate the text to the left of the bar
plots.annotate(f"{change}%",
xy=(left_bar.get_x() + left_bar.get_width() / 2, (left_bar.get_height() + right_bar.get_height()) / 2),
size=10, color='red', ha='right', va='center')
## for expected greater than actual, the annotation starts from the right bar
else:
# print(change, right_bar.get_x())
plots.annotate(f"",
xy=(right_bar.get_x() + right_bar.get_width() / 2, right_bar.get_height()),
ha='center', va='center', size=10,
xytext=(right_bar.get_x() + right_bar.get_width() / 2, left_bar.get_height()),
arrowprops=dict(arrowstyle="simple", color='red', facecolor='red'))
## annotate the text to the right of the bar
plots.annotate(f"{change}%",
xy=(right_bar.get_x() + right_bar.get_width() / 2, (left_bar.get_height() + right_bar.get_height()) / 2),
size=10, color='red', ha='left', va='center')
plt.show()
